Drupalwoo ☉

Build a Dynamic Drupal Block with Visibility Rules

Campaign teams, councils and membership sites often need a Drupal block that appears only in selected places. A training panel might belong on article nodes, while a shipping notice belongs on one commerce route. A static placement can leave empty regions, show the wrong message or force editors to manage exceptions across several layout screens.

The pattern below creates a configurable block plugin that evaluates the current route and, when available, the node being viewed. It keeps business rules out of templates, gives editors a small form and makes caching explicit. The matching service can later be extracted into a shared Drupal condition plugin when several blocks need the same policy.

Choose the right implementation path

Start by choosing the smallest implementation that fits. Drupal's built-in visibility settings cover routine path, language and role decisions. A plugin-specific form suits a rule owned by one block, while a custom condition plugin suits a policy shared by many blocks. Matching several kinds of context inside a template usually pushes too much logic into the wrong layer.

Approach Best suited to Main benefit Main constraint
Core block visibility Simple path, language or role rules Built in and familiar to editors Limited to available conditions
Plugin-specific settings One block with content and route rules Simple ownership and focused testing Rules must be repeated if reused
Custom condition plugin Shared policy across blocks and entities Central configuration and reusable matching More APIs and integration work

For this use case, plugin-specific settings offer the best balance. Editors see content-type and route choices beside the block's message, developers can unit-test the matching service, and core visibility can still handle a separate concern such as restricting the placement to authenticated users. A reusable condition becomes sensible only after the rule is proven in more than one location.

Keep the stored policy declarative: selected content types, selected route paths and a publication flag. Do not store executable PHP or complete request objects in configuration. The smaller the configuration surface, the easier it is to validate, export and deploy across Australian staging and production environments.

Define the visibility contract

Write the rule as a contract before coding. Decide whether it applies to node routes, non-node routes or both; identify whether content type and published status are mandatory; and define what happens when a route has no node. For this block, a node route uses its content type and publication state, while a non-node route uses an exact route path. This gives each request one predictable branch.

Choose fields that editors understand rather than exposing route internals indiscriminately. A checkbox list of content types is preferable to typing bundle names, and a short route-path list is adequate for a site-specific block. If the policy grows to include taxonomy terms, account roles, campaign cohorts or several node types with combinatorial rules, move it behind a service and consider a condition plugin.

Create the custom block plugin

Create a small custom module rather than editing core. Put the plugin in modules/custom/drupalwoo_promo/src/Plugin/Block/EditorialPromoBlock.php and declare a module version compatible with the site's Drupal major. A dedicated namespace avoids collisions, while a custom module can be versioned and deployed without overwriting site-specific work.

The plugin needs the current route match and an entity type manager. Both services are injected rather than retrieved from the global container:

/**
 * @Block(
 *   id = "drupalwoo_editorial_promo",
 *   admin_label = @Translation("Editorial promo"),
 *   category = @Translation("Drupalwoo")
 * )
 */
final class EditorialPromoBlock extends BlockBase implements ContainerFactoryPluginInterface {
  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    RouteMatchInterface $route_match,
    EntityTypeManagerInterface $entity_type_manager
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
    $this->routeMatch = $route_match;
    $this->entityTypeManager = $entity_type_manager;
  }

  public static function create(
    ContainerInterface $container,
    array $configuration,
    $plugin_id,
    $plugin_definition
  ) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('router.route_match'),
      $container->get('entity_type.manager')
    );
  }
}

Dependency injection keeps business logic independent from Drupal's global state and makes the plugin straightforward to instantiate in tests. Extend BlockBase so Drupal can place it through the standard block layout, then define configuration defaults used by both the editor form and the matching code.

Add configurable block settings

The settings form should translate Drupal data into useful labels. Load node types through the entity type manager and use each type's label as the checkbox text, while retaining the machine name as the value. Store the selected values in plugin configuration rather than creating a separate content entity.

public function defaultConfiguration() {
  return [
    'allowed_content_types' => ['article'],
    'allowed_route_paths' => [],
    'require_published' => TRUE,
    'message' => 'Practical Drupal guidance for your team.',
  ] + parent::defaultConfiguration();
}

public function blockForm($form, FormStateInterface $form_state) {
  $options = [];
  foreach ($this->entityTypeManager->getStorage('node_type')->loadMultiple() as $id => $type) {
    $options[$id] = $type->label();
  }

  $form['allowed_content_types'] = [
    '#type' => 'checkboxes',
    '#title' => $this->t('Allowed content types'),
    '#options' => $options,
    '#default_value' => $this->configuration['allowed_content_types'],
    '#tree' => TRUE,
  ];

  $form['allowed_route_paths'] = [
    '#type' => 'textfield',
    '#title' => $this->t('Allowed route paths'),
    '#default_value' => implode(', ', $this->configuration['allowed_route_paths']),
  ];

  $form['require_published'] = [
    '#type' => 'checkbox',
    '#title' => $this->t('Hide on unpublished content'),
    '#default_value' => $this->configuration['require_published'],
  ];

  return $form;
}

Normalise submitted values in blockSubmit(): cast the content-type value to an array, remove empty values and split the route list on commas. Validate that at least one content type or route is present, then reject malformed paths. Describe paths as internal Drupal routes, such as /contact, rather than promising support for arbitrary alias patterns.

A message field can sit alongside the visibility settings, but sanitise it as plain text unless the block deliberately renders trusted markup. Returning the message through #plain_text reduces the chance that an editor can inject markup into the page.

Match routes and content types

The matcher should ask the route match for the current route and its parameters. Drupal's node parameter converter normally supplies a NodeInterface object on canonical node routes, which avoids a second content lookup. If that parameter is absent, treat the request as a non-node route instead of guessing from the URL.

public function build(): array {
  $match = $this->routeMatch->getMatch();
  $parameters = $match->getParameters();
  $node = $parameters['node'] ?? NULL;

  if (!($node instanceof NodeInterface)) {
    $node = NULL;
  }

  $route_path = $match->getRouteObject()->getPath();
  if (!$this->isVisible($node, $route_path)) {
    return [];
  }

  $tags = ['node_list'];
  if ($node) {
    $tags = array_merge($tags, $node->getCacheTags());
  }

  return [
    '#type' => 'container',
    '#attributes' => ['class' => ['editorial-promo']],
    '#content' => ['#plain_text' => $this->configuration['message']],
    '#cache' => [
      'contexts' => ['route.path', 'user.permissions'],
      'tags' => $tags,
      'max-age' => 300,
    ],
  ];
}

protected function isVisible(?NodeInterface $node, string $route_path): bool {
  $types = $this->configuration['allowed_content_types'];
  $paths = $this->configuration['allowed_route_paths'];

  if ($node) {
    if (!in_array($node->bundle(), $types, TRUE)) {
      return FALSE;
    }
    return !$this->configuration['require_published'] || $node->isPublished();
  }

  return in_array($route_path, $paths, TRUE);
}

On a node route, the block appears only when its bundle is in the selected content-type list. The publication checkbox can reject drafts and unpublished content. On a non-node route, the exact route path must be selected. These branches are deliberately separate, so adding /news cannot accidentally expose an unrelated content type.

Returning an empty render array is enough to suppress the block, but it is not an access-control mechanism. If the message or attached data is sensitive, guard it with entity access, route access and permissions. Visibility decides presentation; Drupal's access system decides what a user is allowed to see.

Handle caching, access and edge cases

Dynamic blocks need correct cache metadata because Drupal may reuse the rendered result for another request. Include the route.path context so the render varies by route, and add user.permissions when the block or its content depends on capabilities. For a node-aware block, merge the node's cache tags with node_list, then use a short maximum age as a safety net rather than a substitute for invalidation.

Anonymous and authenticated users may receive different block markup or access, so test both roles and clear the relevant render cache when configuration changes. Avoid caching protected data in a shared page cache. For unpublished content, make the rule explicit and test that only authorised users can reach the relevant route before the block builds.

Also account for requests that have no normal node: taxonomy pages, Views, search results, redirects and error pages. They can have a route but no node parameter. Decide whether they should follow the non-node rule, and make that behaviour visible in the editor-facing description instead of leaving it to chance.

Test, deploy and apply the pattern

Test a matrix rather than checking one lucky URL. Cover each selected and unselected content type, published and unpublished nodes, matching and non-matching routes, anonymous and authenticated roles, and a cache rebuild. Confirm that time-based messages use the configured Drupal timezone: Adelaide runs 30 minutes behind Sydney and Melbourne, while Queensland and Western Australia do not observe daylight saving.

Australian retail campaigns often run around the end of the financial year or Christmas and quote prices in Australian dollars. A visibility rule can control where a promotion appears, but its wording must still comply with the Australian Consumer Law and ACCC expectations, particularly if the block announces a discount, saving or qualification that is not universally available.

Treat customer segmentation as a privacy decision. Geo-IP checks, logged-in customer attributes and personalised campaign blocks can involve personal information under the Privacy Act 1988 and the Australian Privacy Principles. Collect only what the campaign needs, explain its purpose, and avoid using visibility rules to conceal material from consumers. If the campaign includes email or SMS messaging, also check consent and unsubscribe requirements under the Spam Act 2003.

Deploy the module and configuration through the normal version-controlled workflow, enable it selectively, and run cache rebuilds after a release. Once those checks pass, each visible placement has a defined route or content basis, while access, privacy and caching controls remain separate from presentation.