module

install_libraries_locally.sh
composer require npm-asset/yaireo--tagify:4.35.4
my_module.module
/**
 * Implements hook_library_info_alter().
 *
 * The tagify module loads Tagify from cdnjs.cloudflare.com. Serve it from
 * npm-asset/yaireo--tagify in /libraries instead, so no request reaches a
 * third party and the widget keeps working behind restrictive firewalls.
 */
function my_module_library_info_alter(array &$libraries, string $extension): void {
  if ($extension !== 'tagify') {
    return;
  }

  // Assigning replaces the CDN entries. The npm package ships an unminified
  // UMD build and no *.min.css; Drupal aggregates them either way.
  $libraries['tagify']['js'] = [
    '/libraries/yaireo--tagify/dist/tagify.js' => ['minified' => FALSE],
  ];
  $libraries['tagify']['css']['component'] = [
    '/libraries/yaireo--tagify/dist/tagify.css' => ['minified' => FALSE],
  ];
  $libraries['tagify_polyfils']['js'] = [
    '/libraries/yaireo--tagify/dist/tagify.polyfills.min.js' => ['minified' => TRUE],
  ];

  unset($libraries['tagify']['remote'], $libraries['tagify_polyfils']['remote']);
}

Option 1 (recommended): custom entity reference selection handler. Further options down below.

ActiveUserSelection.php
<?php

namespace Drupal\my_module\Plugin\EntityReferenceSelection;

use Drupal\Core\Entity\Plugin\EntityReferenceSelection\UserSelction;
use Drupal\Core\StringTranslation\TranslatableMarkup;

#[EntityReferenceSelection(
  id: "default:active_user",
  label: new TranslatableMarkup("User selection (only active users)"),
  entity_types: ["user"],
  group: "default",
  weight: 1
)]
class ActiveUserSelection extends UserSelection {

  /**
   * {@inheritdoc}
   */
  protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') {
    $query = parent::buildEntityQuery($match, $match_operator);
    $query->condition('status', 1);

    return $query;
  }

}

After implementing that plugin, you need to activate it on the entity form display for the field you want it.

A Drupal backend screenshot showing the "Count down message" field

Leave this field empty to allow for translation via Drupal interface translation.


Example configuration created by enabling Maxlength:

core.entity_form_display.node.article_not_translatable.default.yml
# ...
content:
  field_copyright:
    type: text_textarea
    weight: 3
    region: content
    settings:
      rows: 2
      placeholder: ''
    third_party_settings:
      allowed_formats:
        hide_help: '1'
        hide_guidelines: '1'
      maxlength:
        maxlength_js: null
        maxlength_js_label: 'Content limited to @limit characters, remaining: <strong>@remaining</strong>'
        maxlength_js_enforce: false
# ...

However, the module already contains fallback logic for this label in maxlength.module: 

maxlength.module
$maxlength_js_label = !empty($thirdPartySettings['maxlength']['maxlength_js_label']) ? $thirdPartySettings['maxlength']['maxlength_js_label'] : t('Content limited to @limit characters, remaining: <strong>@remaining</strong>');
$maxlength_js = $thirdPartySettings['maxlength']['maxlength_js'];

Working configuration: empty the label

core.entity_form_display.node.article_translatable.default.yml
# ...
content:
  field_copyright:
    type: text_textarea
    weight: 3
    region: content
    settings:
      rows: 2
      placeholder: ''
    third_party_settings:
      allowed_formats:
        hide_help: '1'
        hide_guidelines: '1'
      maxlength:
        maxlength_js: null
        maxlength_js_label: ''
        maxlength_js_enforce: false
# ...

With the label empty, the module falls back to its internal t() string, which can then be translated normally via the interface translation system.

my_module.module
use Drupal\views\ViewExecutable;
use Drupal\my_module\Access\NodePageAccessControlHandler;
use Drupal\my_module\Access\RestrictedMediaAccessControlHandler;


/**
 * Implements hook_entity_type_alter().
 */
function my_module_entity_type_alter(array &$entity_types) {
  if (isset($entity_types['node'])) {
    $entity_types['node']->setAccessClass(NodePageAccessControlHandler::class);
  }
  if (isset($entity_types['media'])) {
    $entity_types['media']->setAccessClass(RestrictedMediaAccessControlHandler::class);
  }
}

/**
 * Implements hook_views_post_execute().
 */
function my_module_views_post_execute(ViewExecutable $view) {
  // Only execute if we are on the right view display.
  if ($view->id() === 'MY_VIEW_ID' && $view->current_display === 'MY_VIEW_DISPLAY' ) {

    // Mark this view results as token-allowed in the session for a short time.
    $ttl = 3600; // 1 hour; adjust to taste.
    $now = \Drupal::time()->getRequestTime();
    $session = \Drupal::service('request_stack')->getCurrentRequest()->getSession();
    $grants = (array) $session->get('my_module.token_mids', []);
    foreach ($view->result as $item) {
      $grants[(int) $item->mid] = $now + $ttl;
    }
    $session->set('my_module.token_mids', $grants);
  }
}

NodePageAccessControlHandler.php
namespace Drupal\my_module\Access;

/**
 * Override Node Access Control Handler.
 */
class NodePageAccessControlHandler extends NodeAccessControlHandler {

  ...

  /**
   * {@inheritdoc}
   */
  public function access(EntityInterface $entity, $operation, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
    if ($operation === 'view') {
      // Mark this node as token-allowed in the session for a short time.
      $ttl = 3600; // 1 hour; adjust to taste.
      $now = $this->time->getRequestTime();
      $session = $this->requestStack->getSession();
      $grants = (array) $session->get('my_module.token_nids', []);
      $grants[(int) $entity->id()] = $now + $ttl;
      $session->set('my_module.token_nids', $grants);
    }
}

RestrictedMediaAccessControlHandler.php
namespace Drupal\my_module\Access;

/**
 * Override Media Access Control Handler.
 */
class RestrictedMediaAccessControlHandler extends MediaAccessControlHandler {

  /**
   * {@inheritdoc
   *
   *  Restrict anonymous view of Media (bundle "restricted_file") to cases where
   *  the user can view the host Node (via paragraph field
   * 'field_download_restricted' or a view listing media entities), 
   * OR they have a valid short-lived token grant for that Node stored in session.
   */
  public function access(EntityInterface $entity, $operation, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
    if (
      $operation !== 'view'
      || !$account
      || $entity->bundle() !== 'restricted_file'
    ) {
      return parent::access($entity, $operation, $account, $return_as_object);
    }

    $paragraphs = $this->entityTypeManager
      ->getStorage('paragraph')
      ->loadByProperties(['field_download_restricted' => $entity->id()]);

    // Check if we have a media entity id and a cookie for it does exist.
    $media_entity = NULL;
    if (array_key_exists($entity->id(), $mgrants = (array) $this->requestStack->getSession()->get('my_module.token_mids', []))) {
      $media_entity = $this->entityTypeManager
        ->getStorage('media')
        ->loadByProperties(['mid' => $entity->id()]);
      $now = $this->time->getRequestTime();

      // Session-granted access via token.
      $mid = (int) $entity->id();
      if (!empty($mgrants[$mid]) && $mgrants[$mid] >= $now) {
        return AccessResult::allowed()
          ->addCacheableDependency($entity)
          ->addCacheableDependency(reset($media_entity))
          // Important for anon/session-based grants.
          ->mergeCacheMaxAge(0);
      }
    }  

    $result = AccessResult::forbidden()
      ->addCacheableDependency($entity)
      ->cachePerPermissions();

    if (!$paragraphs && !$media_entity) {
      return $result;
    }

    // Read session grants set by my_module_node_access().
    $now = $this->time->getRequestTime();
    $grants = (array) $this->requestStack->getSession()->get('my_module.token_nids', []);

    foreach ($paragraphs as $paragraph) {
      if ($paragraph instanceof ParagraphInterface) {
        $result = $result->addCacheableDependency($paragraph);
      }

      $node = $this->getAncestorEntity($paragraph);
      if (!$node instanceof NodeInterface) {
        continue;
      }

      // Always add the node as a dependency.
      $result = $result->addCacheableDependency($node);

      // Session-granted access via token.
      $nid = (int) $node->id();
      if (!empty($grants[$nid]) && $grants[$nid] >= $now) {
        return AccessResult::allowed()
          ->addCacheableDependency($entity)
          ->addCacheableDependency($node)
          // Important for anon/session-based grants.
          ->mergeCacheMaxAge(0);
      }

      // Existing rule: inherit from node view access.
      if ($node->access('view', $account)) {
        return AccessResult::allowed()
          ->addCacheableDependency($entity)
          ->addCacheableDependency($node)
          ->cachePerPermissions();
      }

      return $result;
    }
  }

}

my_module_with_presave_hook.module
<?php

/**
 * @file
 * Primary module hooks for my module.
 */

/**
 * Implements hook_ENTITY_TYPE_presave().
 */
function my_module_node_presave(\Drupal\Core\Entity\EntityInterface $entity) {
  if ($entity->bundle() === 'article') {
    // Do something here with the article.
  }
}

/**
 * Implements hook_entity_presave().
 */
function my_module_entity_presave(\Drupal\Core\Entity\EntityInterface $entity) {
  if ($entity instanceof \Drupal\node\NodeInterface || $entity instanceof \Drupal\paragraphs\Entity\Paragraph) {
    // Do something here with the node and paragraph.
  }
}

10.07.2025 | Lothar Ferreira Neumann

Presave hook on one or multiple entities

my_module.install
function my_module_update_XXXXX() {
  $entity_type_manager = \Drupal::entityTypeManager();

  $old_terms = $entity_type_manager->getStorage('taxonomy_term')->loadByProperties(['vid' => 'old_tags']);
  foreach ($old_terms as $term) {
    $term->vid->target_id = 'new_tags';
    $term->save();
  }

  $nodes = $entity_type_manager->getStorage('node')->loadByProperties(['type' => 'my_bundle']);
  foreach ($nodes as $node) {
    $node_old_terms = $node->field_old_terms->getValue();
    foreach ($node_old_terms as $old_term) {
      $node->field_new_tags[] = $old_term;
    }
    $node->save();
  }

}

my_module.module
function my_module_preprocess_field(&$variables): void {
  if ($variables['element']['#bundle'] == 'my_bundle') {
    if (in_array($variables['element']['#field_name'], ['my_field'])) {
      $variables['attributes']['lang'] = 'en';
    }
  }
}

Issue: https://www.drupal.org/project/drupal/issues/953034

my_module.module
<?php

use Drupal\views\Plugin\Block\ViewsBlock;

/**
 * Implements hook_block_alter().
 */
function hook_block_alter(array &$definitions) {
  foreach ($definitions as $block_id => &$block_definition) {
    if ($block_definition['class'] === ViewsBlock::class) {
      $block_definition['class'] = 'Drupal\my_module\Plugin\Block\ViewsBlock';
    }
  }
}

ViewsBlock.php
<?php

namespace Drupal\my_module\Plugin\Block;

use Drupal\views\Plugin\Block\ViewsBlock as ViewsBlockCore;

/**
 * Replaces the generic Views block.
 */
class ViewsBlock extends ViewsBlockCore {

  /**
   * {@inheritdoc}
   */
  public function build() {
    if (empty($this->view->result) && empty($this->view->empty)) {
      // Without caching.
      // return ['#cache' => ['max-age' => 0]];
      // With hard caching.
      return [];
    }

    return parent::build();
  }

}

my_module.module
<?php

/**
 * Implements hook_theme_suggestions_HOOK_alter() for table.
 */
function hook_theme_suggestions_table_alter(array &$suggestions, array $variables): void {
  if (empty($variables['attributes']['class'])) {
    return;
  }

  if (is_array($variables['attributes']['class']) && in_array('ief-entity-table', $variables['attributes']['class'])) {
    $suggestions[] = 'table__simple';
  }
}
22.06.2026 | Pascal Crott

Gin: Make use of the simple table

⚠️ WARNING: Potential data loss. Use with caution.

scp_base.module
/**
 * Run missing database schema updates.
 */
function mymodule_update_10001(&$sandbox) {
  $entity_type_manager = \Drupal::entityTypeManager();
  $entity_type_manager->clearCachedDefinitions();
  $change_summary = \Drupal::service('entity.definition_update_manager')->getChangeSummary();
  foreach ($change_summary as $entity_type_id => $change_list) {
    $entity_type = $entity_type_manager->getDefinition($entity_type_id);
    \Drupal::entityDefinitionUpdateManager()->installEntityType($entity_type);
  }
}
07.05.2026 | Peter Gerken

Run missing entity Schema updates