Skip to main content

Drupal 9.3: Creating Bundle Subclasses

Entity bundles are essentially business objects, and now they can declare their own class, encapsulating the required business logic. A bundle class must be a subclass of the base entity class, such as \Drupal\node\Entity\Node. Modules can define bundle classes for their own entities by defining the class property in hook_entity_bundle_info(), and can alter bundle classes of entities defined in other modules with hook_entity_bundle_info_alter(). Each bundle subclass must be unique. If you attempt to reuse a subclass for multiple bundles, an exception will be thrown.

from https://www.drupal.org/node/3191609

After reading this we have to see what do we need to create a bundle subclass?

We need a custom module. Let's call this module custom and in web/modules/custom/src/Entity folder we add the Bundle Subclass and the Interface of the class (this is not mandatory, you can skip this for simple tasks).

So if our node bundle is Recipe then we will have into our custom module src/Entity folder these two php files (a class and an interface):

  • web/modules/custom/src/Entity/Recipe.php (from the machine name of the node bundle)
  • web/modules/custom/src/Entity/RecipeInterface.php

The RecipeInterface.php file

<?php

namespace Drupal\custom\Entity;

/**
 * Defines the interface for Node CT of Recipe.
 */
interface RecipeInterface {

  /**
   * Get all related Recipe products.
   *
   * @return array
   *   Returns an array with the related Recipe products ordered by Year.
   */
  public function getSimilarRecipes(): array;

  /**
   * Get POS Recipe Product Data.
   *
   * @return array
   *   Returns an array with available POS Recipe Product Data
   */
  public function getPosRecipeProductData(): array;

  /**
   * Get Product Recipe Related Knowledge Article Media.
   *
   * @return array
   *   Returns an array with the Related Knowledge Article Media if any
   */
  public function getRelatedMedia(): ? array;

}

and the actual bundle class php file has to implement the RecipeInterface of course.

<?php

namespace Drupal\custom\Entity;

use Drupal\file\Entity\File;
use Drupal\node\Entity\Node;
use Drupal\image\Entity\ImageStyle;

/**
 * Bundle-specific subclass of Node for Node Bundle Recipe (Recipe).
 */
class Recipe extends Node implements RecipeInterface {

  /**
   * {@inheritdoc}
   */
  public function getSimilarRecipes(): array {

    $product_id = $this->get('field_recipe_product')->target_id;
    $links = [];
    $data = [];
    $data['id'] = $product_id;

    // Load other Recipes for this product to generate links.
    $recipes = $this->getProductsFromDb($product_id);
    if (empty($recipes)) {
      return [];
    }

    $current_path = \Drupal::request()->getRequestUri();

    foreach ($recipes as $recipe) {
      if ($recipe->get('field_Recipe_Recipe_year')->entity->name->value === 'All') {
        continue;
      }

      $url = $recipe->toUrl()->toString();

      if ($url === $current_path) {
        $data['active_year'] = $recipe->get('field_Recipe_Recipe_year')->entity->name->value;
      }
      else {
        $links[] = [
          'title' => $recipe->get('field_Recipe_Recipe_year')->entity->name->value,
          'url' => $url,
          'is_active' => ($url === $current_path),
        ];
      }
    }

    // If this is not published the Dropdown list is empty.
    if (!isset($data['active_year']) || !$this->isPublished()) {
      $data['active_year'] = 'Latest: ' . $this->get('moderation_state')->value;
    }

    $data['items'] = $links;

    return $data;
  }

  /**
   * {@inheritdoc}
   */
  public function getRelatedMedia(): ?array {

    $related_media = $this->getRelatedMediaFromDB();

    if ($related_media) {
      return $this->getRelatedMediaTeaserData($related_media);
    }
    return NULL;
  }

  /**
   * Return an array with any similar products from a different year.
   *
   * @param int $node_id
   *   The Import Product Recipe Node.
   *
   * @return array
   *   Same Product different year of production.
   */
  private function getProductsFromDb(int $node_id) {

    $recipes = [];
    $storage = $this->entityTypeManager()->getStorage('node');

    $query = $storage->getQuery()
      ->condition('status', 1)
      ->condition('type', 'Recipe')
      ->condition('field_Recipe_product.target_id', $node_id)
      ->sort('field_Recipe_Recipe_year.entity.name', 'desc');
    $recipe_nids = $query->execute();

    if (!empty($recipe_nids)) {
      $recipes = $storage->loadMultiple($recipe_nids);
    }

    return $recipes;
  }

  /**
   * Return an array with any related  article media .
   *
   * @return array
   *   Article Related Media
   */
  private function getRelatedMediaFromDb(): ?array {

    $country_node_id = $this->geCountryNodeId();

    if (!$country_node_id) {
      return NULL;
    }

    $storage = $this->entityTypeManager()->getStorage('node');

    $query = $storage->getQuery()
      ->condition('status', 1)
      ->condition('type', 'recipe_article')
      ->condition('field_country_ref.target_id', $country_node_id)
      ->range(0, 3)
      ->sort('created', 'desc');

    $related_media_nids = $query->execute();
    if (is_array($related_media_nids) && count($related_media_nids) > 0) {
      return $related_media_nids;
    }

    return NULL;
  }

  /**
   * Return an array with the teaser data from the related Recipe media.
   *
   * @return array
   *   Related Media Teaser Data
   */
  private function getRelatedMediaTeaserData(array $related_media): array {

    global $base_url;
    $image_style = 'featured_content_thumb';
    $related_media_data = [];
    $img = $base_url . '/themes/custom/custom2/assets/images/related_media_default.jpg';

    foreach ($related_media as $related_media_nid) {
      $node = Node::load($related_media_nid);
      $alias = \Drupal::service('path_alias.manager')->getAliasByPath('/node/' . $related_media_nid);
      if ($node->hasField('field_teaser_image') && !$node->get('field_teaser_image')->isEmpty()) {
        $media = $node->get('field_teaser_image')->entity;
        $fid = $media->get('field_media_image')->target_id;
        $file = File::load($fid);
        $img = ImageStyle::load($image_style)->buildUrl($file->getFileUri());
      }
      $related_media_data[$related_media_nid] = [
        'title' => $node->label(),
        'img' => $img,
        'url' => $alias,
      ];
    }
    return $related_media_data;
  }

}

After this we can go directly into node--bundle.html.twig file and call the methods like this

web/themes/custom/mytheme/templates/content/node--recipe--full.html.twig

{% set similar_recipes = node.getSimilarRecipes() %}
{% if similar_recipes %}
  <div class="recipe__year mb-5">
	 {% include "@mytheme/dropdown/dropdown.twig" with {
          'id': similar_recipes.id,
          'label': similar_recipes.active_year,
          'items': similar_recipes.items,
        } only
      %}
  </div>
{% endif %}

 

entity bundle custom module