Block Visibility Condition Plugin

May 27, 2025

I've built some custom block visibility condition plugins in the past, but infrequently enough that I have to relearn how to do it each time. Fortunately the process is pretty well documented—in fact, I'm not going to bother to write up explanations for everything here because it's already been done by much better developers than me. I'm particularly indebted to Jaypan's excellent tutorial—anything that I'm glossing over here is well-explained there.

I needed a bit of additional functionality, however, and I decided that what I ended up with was worth documenting—specifically, I was aiming to:

The specific use case here is adding a condition based on location, where location is a taxonomy term selected by (typically anonymous) users and stored in a cookie. A lot of the location-related functionality was already present in the custom module before I added the block visibility condition, so there are some references to custom services that provide all of the available locations ($this->availableLocations->locations()) and the current location from the cookie ($this->selectedLocation->location())—the details of these aren't really relevant to what I'm focused on here.

Here's the full plugin, assuming the module name is custom_location:

<?php
namespace Drupal\custom_location\Plugin\Condition;
use Drupal\Core\Condition\ConditionPluginBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\custom_location\AvailableLocations;
use Drupal\custom_location\SelectedLocation;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
 * Provides a condition for the current user's selected location.
 *
 * This condition evaluates to TRUE when the current user's location is among
 * the locations specified in the block visibility configuration.
 *
 * @Condition(
 *   id = "custom_location",
 *   label = @Translation("Locations")
 * )
 */
class Location extends ConditionPluginBase implements ContainerFactoryPluginInterface {
  /**
   * All available locations.
   *
   * @var \Drupal\custom_location\AvailableLocations
   */
  protected $availableLocations;
  
  /**
   * The selected location.
   *
   * @var \Drupal\custom_location\SelectedLocation
   */
  protected $selectedLocation;
  
  /**
   * @param array $configuration
   *   The plugin configuration.
   * @param string $plugin_id
   *   The plugin_id for the plugin instance.
   * @param mixed $plugin_definition
   *   The plugin implementation definition.
   * @param \Drupal\custom_location\AvailableLocations $availableLocations
   *   All available locations.
   * @param \Drupal\custom_location\SelectedLocation $selectedLocation
   *   The selected location.
   */
  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    AvailableLocations $availableLocations,
    SelectedLocation $selectedLocation,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
    $this->availableLocations = $availableLocations;
    $this->selectedLocation = $selectedLocation;
  }
  
  public static function create(
    ContainerInterface $container,
    array $configuration,
    $plugin_id,
    $plugin_definition,
  ) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('custom_location.available_locations'),
      $container->get('custom_location.selected_location'),
    );
  }
  
  /**
   * The default value leaves the block visible.
   */
  public function defaultConfiguration() {
    return ['location' => []] + parent::defaultConfiguration();
  }
  
  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
    $form['location'] = [
      '#title' => $this->t('Locations'),
      '#type' => 'checkboxes',
      '#options' => $this?->availableLocations?->locations(),
      '#default_value' => $this?->configuration['location'] ?? [],
      '#attributes' => [
        'data-custom-location-summary-source' => 1,
        'data-summary-on' => $this->summary(TRUE),
        'data-summary-off' => $this->summary(FALSE),
      ],
    ];
    return parent::buildConfigurationForm($form, $form_state);
  }
  
  /**
   * Save the selected value(s) to configuration.
   */
  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
    $this->configuration['location'] = $form_state?->getValue('location');
    parent::submitConfigurationForm($form, $form_state);
  }
  
  /**
   * The status parameter is added to allow the summary to be made available to
   * the JavaScript that shows it.
   */
  public function summary(?bool $status = NULL) {
    $summary_status = (is_bool($status)) ? $status : $this?->configuration['location'];
    return ($summary_status) ? $this->t('Restricted to certain locations') : $this->t('Not restricted');
  }
  
  /**
   * Note that isNegated() happens automatically. See
   * https://www.drupal.org/project/drupal/issues/2535896.
   */
  public function evaluate() {
    $return = FALSE;
    $locations = ($this?->configuration['location']) ?? [];
    if (($user_location = $this?->selectedLocation?->location())
    && (in_array($user_location, $locations))) {
      $return = TRUE;
    }
    return $return;
  }
  
  public function getCacheContexts() {
    return array_merge([
      'cookies:custom_location',
    ], parent::getCacheContexts());
  }
}

I removed a bunch of {@inheritdoc} comments here to save some space, so this doesn't meet coding standards as-is. A few things to notice:

  1. In buildConfigurationForm(), note the custom attributes on the form field. Although the plugin itself provides a summary of the configuration settings, that summary doesn't appear in the block configuration screen's vertical tabs without some additional JavaScript (below). Adding the required JavaScript is fine, but it's always bothered me to have to duplicate the summary text there—I feel like user-facing text in a module should be written once and reused when necessary; I can easily imagine a situation where a developer is changing the text in the plugin, isn't seeing the text change in the configuration form (because it's duplicated in the JavaScript), and is consequently tearing their hair out. With the summary text added as custom parameters to the condition form field the associated JavaScript can retrieve it from the HTML source, so it only has to be recorded once.
  2. For related reasons, I've added the $status parameter to summary()—this just allows me to retrieve the “on” and “off” summary text manually for the form field attributes while still deferring to the stored block configuration when the parameter is not provided (again just to ensure that the actual text only has to be written once).
  3. In his tutorial, Jaypan uses the isNegated() method to provide different summary text depending on whether or not the condition is negated. I decided not to do this, just for consistency with the core “Pages” plugin, but something important to notice: while isNegated() has to be called manually if you want to use it in summary(), it's automatically accounted for in evaluate() and attempting to account for it manually will just lead to confusion.
  4. Because this module is using the user's location cookie to check the visibility, the cache context is cookies:custom_location.

The JavaScript for the summary is attached as a library, so it has to be defined in the module's .libraries.yml file:

block-visibility:
  js:
    js/jquery.custom-location-block-visibility.js: {}
  dependencies:
    - block/drupal.block

In the .module file, the block configuration form is altered to attach the library and hide/show the “Negate the condition” checkbox depending on whether any conditions are active (because it's irrelevant if none are):

/**
 * Implements hook_form_FORM_ID_alter().
 *
 * Attach the vertical tab JavaScript library provided by this module, and hide
 * the “Negate the condition” checkbox on the location block visibility element
 * when no location is selected.
 */
function custom_location_form_block_form_alter(&$form, FormStateInterface $form_state) {
  if (isset($form['visibility']['custom_location'])) {
    $form['visibility_tabs']['#attached']['library'][] = 'custom_location/block-visibility';
    $form['visibility']['custom_location']['negate']['#states'] = [
      'visible' => [
        '[data-drupal-selector^="edit-visibility-custom-location-location-"]' => [
          'checked' => TRUE,
        ],
      ],
    ];
  }
}

I'm going to come back to the .module file to add some functionality that isn't directly relevant to the plugin, but first I'll finish dealing with the summary text.

Avoiding Duplication of Block Condition Summary Text

Here's the JavaScript to make the summary text visible on the vertical tabs of the block configuration form:

(function ($, Drupal) {
  'use strict';
  /**
   * Provide summary information for block settings vertical tabs.
   *
   * @type {Drupal~behavior}
   *
   * @prop {Drupal~behaviorAttach} attach
   *   Attaches the behavior for the block settings summaries.
   */
  Drupal.behaviors.blockSettingsSummaryLocation = {
    attach: function () {
      // Only do something if the function drupalSetSummary is defined.
      if (jQuery.fn.drupalSetSummary !== undefined) {
        // Set the summary on the vertical tab.
        $('[data-drupal-selector="edit-visibility-custom-location"]').drupalSetSummary(locationSummary);
      }
    }
  };
  function locationSummary(context) {
    let summary, summarySource;
    if ((summarySource = $(context).find('[data-custom-location-summary-source]'))
    && (summarySource.length)) {
      let conditionChecked = $(context).find('[data-drupal-selector^="edit-visibility-custom-location-location-"]:checked').length;
      summary = (conditionChecked) ? summarySource.attr('data-summary-on') : summarySource.attr('data-summary-off');
    }
    return summary;
  }
}(jQuery, Drupal));

The actual text of the summary is retrieved from custom attributes added in the buildConfigurationForm() method of the plugin, so there's no need to duplicate it (see here for more information about adding the attributes).

Processing Block Conditions Programmatically

One of the features that this module provided before I added the block visibility condition plugin was to keep track of location-dependent elements on the page: this enabled the page to be automatically reloaded when the location was changed if (but only if) the page contained location-dependent elements.

My first crack at extending this to work with the block visibility condition was using template_preprocess_block(), but the problem with this approach surfaced quickly: if the block was visible and had the reload trigger class, the page was properly reloaded (and the block disappeared) when the location was changed to one for which the block should not be visible; however if the block wasn't visible, changing the location to one that should have made it appear didn't trigger a reload because the trigger class wasn't anywhere in the page (because the block wasn't anywhere in the page). To get past this, I ultimately ended up using template_preprocess_html() (in the .module file) to implement the conditional reload functionality:

/**
 * Implements template_preprocess_html() for location functionality.
 *
 * Location-dependent block visibility means that blocks may change when the
 * location is changed. Because a location change may make visible a block that
 * wasn't previously present, the reload trigger class can't be added to blocks
 * themselves (because they may not be present yet), so the class is added to
 * the body if any block has location-dependent visibility enabled.
 */
function custom_location_preprocess_html(array &$variables) {
  $blocks = \Drupal::entityTypeManager()?->getStorage('block')?->loadMultiple();
  foreach ($blocks as $block_id => $block) {
    if (($visibility = $block?->getVisibility())
    && (isset($visibility['custom_location']))
    && (is_array($visibility['custom_location']))
    && (!empty($visibility['custom_location']))) {
      // Evaluate all of the conditions except for location, and only add the
      // trigger class if the block would appear with the location condition
      // excluded.
      $conditions_met = 0;
      foreach ($visibility as $instance_id => $instance) {
        if ($instance_id != 'custom_location') {
          $condition = \Drupal::service('plugin.manager.condition')->createInstance($instance_id);
          $condition->setConfiguration($visibility[$instance_id]);
          if ($condition->evaluate()) {
            $conditions_met++;
          }
        }
      }
      if ($conditions_met == (count($visibility) - 1)) {
        $variables['attributes'] ??= [];
        $variables['attributes']['class'] ??= [];
        $variables['attributes']['class'][] = 'custom-location-dependent';
      }
    }
  }
  if ((isset($variables['attributes']['class']))
  && ($variables['attributes']['class'])) {
    $variables['attributes']['class'] = array_unique($variables['attributes']['class']);
  }
}

This iterates through all of the blocks; for any block that uses the location block visibility condition it then iterates through all of the other conditions on that block to determine if, absent the location condition, the block would appear. If it would appear without the location condition, the reload trigger class (custom-location-dependent) is added to the body. (The last few lines just ensure that that class only appears once regardless of how many blocks use the location condition.) Although nothing like this appears in Jaypan's tutorial, there is a section entitled “Using Conditions Programmatically” that pointed me in the right direction.

Providing a Valid Configuration Schema

One last thing: I had some trouble figuring out the configuration schema for a block visibility condition plugin, but got there eventually largely thanks to the Taxonomy Term Entity Block Visibility Condition module—here's what it is for the location module described here:

condition.plugin.custom_location:
  type: condition.plugin
  mapping:
    location:
      type: sequence
      label: 'Location'
      sequence:
        type: integer
    negate:
      type: integer
      label: 'Negate'

The location sequence is specific to this implementation, but the general structure should be applicable to any condition plugin.

A butterfly surrounded by vegitation

Kettle Lakes Provincial Park, Ontario, July 2021