7

Your Own Custom Entity and Plugin Types

I am sure that you are looking forward to applying some of the knowledge gained from the previous chapters and doing something practical and fun. As promised, in this chapter, we will do just that. Also, apart from implementing our own entity types, we will cover some new things as well. So, here’s the game plan.

The premise is that we want to have products on our site that hold some basic information, such as an ID, a name, and a product number. However, these products need to somehow get onto our site. One way will be manual entry. Another, more important way, will be through an import from multiple external sources (such as a JSON endpoint). Now, things will be kept simple. For all intents and purposes, these products aren’t going to do much, so don’t expect an e-commerce solution being laid out in front of you. Instead, we will practice modeling data and functionality in Drupal.

First, we will create a simple content entity type to represent our products. In doing so, we will make sure that we can use the UI to create, edit, and delete these products with ease by taking advantage of many Entity API benefits available out of the box.

Second, we will model our importing functionality. One side of the coin will be a simple configuration entity type to represent the configuration needed for our various importers. Again, we will make use of the Entity API for quick scaffolding and entity management. The other side will be a custom plugin type whose plugins will actually perform the import based on the configuration found in the entities. As such, these will be linked from the direction of the config entities, which will choose to use one plugin or another.

So these are the highlights. In building all this, we will see much of what is needed to define a content and configuration entity type with fields to hold data and configuration, as well as a plugin type to encapsulate logic.

The code we write in this chapter will go inside a new module called products. Since we have learned how to create a module from scratch, I will not cover the initial steps needed for getting started with this.

The main topics we will cover in this chapter are as follows:

  • Creating our own custom entity type
  • Creating our own custom plugin type
  • Writing a custom Drush command

Our custom content entity type

As we saw in the previous chapter, when looking at the Node and NodeType entity types, entity type definitions belong inside the Entity folder of our module’s namespace. In there, we will create a class called Product, which will have an annotation at the top to tell Drupal that this is a content entity type. This is the most important part of defining a new entity type:

namespace DrupalproductsEntity;
use DrupalCoreEntityContentEntityBase;
/**
 * Defines the Product entity.
 *
 * @ContentEntityType(
 *   id = "product",
 *   label = @Translation("Product"),
 *   handlers = {
 *     "view_builder" = "DrupalCoreEntity
         EntityViewBuilder",
 *     "list_builder" = "Drupalproducts
         ProductListBuilder",
 *
 *     "form" = {
 *       "default" = "DrupalproductsFormProductForm",
 *       "add" = "DrupalproductsFormProductForm",
 *       "edit" = "DrupalproductsFormProductForm",
 *       "delete" = "DrupalCoreEntity
           ContentEntityDeleteForm",
 *     },
 *    "route_provider" = {
 *      "html" = "DrupalCoreEntityRouting
          AdminHtmlRouteProvider"
 *    }
 *   },
 *   base_table = "product",
 *   admin_permission = "administer site configuration",
 *   entity_keys = {
 *     "id" = "id",
 *     "label" = "name",
 *     "uuid" = "uuid",
 *   },
 *   links = {
 *     "canonical" = "/admin/structure/product/{product}",
 *     "add-form" = "/admin/structure/product/add",
 *     "edit-form" = "/admin/structure/product/{product}/
         edit",
 *     "delete-form" = "/admin/structure/product/{product}/
         delete",
 *     "collection" = "/admin/structure/product",
 *   }
 * )
 */
class Product extends ContentEntityBase implements
  ProductInterface {}

In the above code block, I omitted the actual contents of the class to first focus on the annotation and some other aspects. We will see the rest of it shortly. However, the entire working code can be found in the accompanying repository.

If you remember the previous chapter, we have the ContentEntityType annotation with the entity type plugin definition. Our example is relatively barebones compared to Node, for example, because I wanted to keep things simple. It has no bundles and is not revisionable, nor translatable. Also, for some of its handlers, we fall back to the Entity API defaults.

The entity type ID and label are immediately visible, so no need to explain that; we can instead skip to the handlers section.

For the view builder handler, we choose to default to the basic EntityViewBuilder because there is nothing specific our products need to be rendered. Most of the time, this will be enough, but you can also extend this class and create your own.

For the list builder, although we are still keeping things simple, we need our own implementation in order to take care of things such as the list headers. We will see this class soon. The form handler for creating and editing products is our own implementation found inside the Form namespace of our module, and we will see it soon to get a better understanding. We rely on Drupal to help us out with the delete form, though.

Finally, for the route provider, we used the default AdminHtmlRouteProvider, which takes care of all the routes necessary for an entity type to be managed in the admin UI. This means that we no longer need to do anything for routing the links referenced in the links section of the annotation. Speaking of links, it makes sense to place them under the admin/structure section of our administration for our example, but you can choose another place if you want.

The database table our products will be stored in is products, and the permission needed for users to manage them is administer site configuration. I have deliberately omitted to create permissions specific to this entity type because we will cover this topic in a chapter dedicated to access. So, we will use this permission that comes with Drupal core.

Finally, we also have some basic entity keys to map to the respective fields.

Our Product class extends the ContentEntityBase class (since we don’t need any “editorial” features such as Node, for example) and implements our very own ProductInterface, which will contain all the methods used to access relevant field values. Let’s create this interface in the same Entity folder:

namespace DrupalproductsEntity;
use DrupalCoreEntityContentEntityInterface;
use DrupalCoreEntityEntityChangedInterface;
/**
 * Represents a Product entity.
 */
interface ProductInterface extends ContentEntityInterface,
  EntityChangedInterface {
  /**
   * Gets the Product name.
   *
   * @return string
   */
  public function getName();
  /**
   * Sets the Product name.
   *
   * @param string $name
   *
   * @return DrupalproductsEntityProductInterface
   *   The called Product entity.
   */
  public function setName($name);
  /**
   * Gets the Product number.
   *
   * @return int
   */
  public function getProductNumber();
  /**
   * Sets the Product number.
   *
   * @param int $number
   *
   * @return DrupalproductsEntityProductInterface
   *   The called Product entity.
   */
  public function setProductNumber($number);
  /**
   * Gets the Product remote ID.
   *
   * @return string
   */
  public function getRemoteId();
  /**
   * Sets the Product remote ID.
   *
   * @param string $id
   *
   * @return DrupalproductsEntityProductInterface
   *   The called Product entity.
   */
  public function setRemoteId($id);
  /**
   * Gets the Product source.
   *
   * @return string
   */
  public function getSource();
  /**
   * Sets the Product source.
   *
   * @param string $source
   *
   * @return DrupalproductsEntityProductInterface
   *   The called Product entity.
   */
  public function setSource($source);
  /**
   * Gets the Product creation timestamp.
   *
   * @return int
   */
  public function getCreatedTime();
  /**
   * Sets the Product creation timestamp.
   *
   * @param int $timestamp
   *
   * @return DrupalproductsEntityProductInterface
   *   The called Product entity.
   */
  public function setCreatedTime($timestamp);
}

As you can see, we are extending not only the obligatory ContentEntityInterface but also the EntityChangedInterface, which provides some handy methods to manage the last changed date of the entities. Those method implementations will be added to our Product class via the EntityChangedTrait:

use EntityChangedTrait;

The methods on the ProductInterface are relatively self-explanatory. We will have a product name, number, remote ID, and source field, so it’s nice to have getters and setters for those. If you remember, the Entity API provides the get() and set() methods with which we can consistently access and store field values across all entity types. However, I find that using an interface with well-defined methods makes code much clearer, not to mention that IDE autocompletion is a great time-saver. We also have a getter and setter for the created date field, which is a typical field that content entities have.

Now, we can take a look at the baseFieldDefinitions() method of our Product entity type and see how we can actually define our fields:

public static function baseFieldDefinitions
  (EntityTypeInterface $entity_type) {
  $fields = parent::baseFieldDefinitions($entity_type);
  $fields['name'] = BaseFieldDefinition::create('string')
    ->setLabel(t('Name'))
    ->setDescription(t('The name of the Product.'))
    ->setSettings([
      'max_length' => 255,
      'text_processing' => 0,
    ])
    ->setDefaultValue('')
    ->setDisplayOptions('view', [
      'label' => 'hidden',
      'type' => 'string',
      'weight' => -4,
    ])
    ->setDisplayOptions('form', [
      'type' => 'string_textfield',
      'weight' => -4,
    ])
    ->setDisplayConfigurable('form', TRUE)
    ->setDisplayConfigurable('view', TRUE);
  $fields['number'] = BaseFieldDefinition::
    create('integer')
    ->setLabel(t('Number'))
    ->setDescription(t('The Product number.'))
    ->setSettings([
      'min' => 1,
      'max' => 10000
    ])
    ->setDisplayOptions('view', [
      'label' => 'above',
      'type' => 'number_unformatted',
      'weight' => -4,
    ])
    ->setDisplayOptions('form', [
      'type' => 'number',
      'weight' => -4,
    ])
    ->setDisplayConfigurable('form', TRUE)
    ->setDisplayConfigurable('view', TRUE);
  $fields['remote_id'] = BaseFieldDefinition::create
    ('string')
    ->setLabel(t('Remote ID'))
    ->setDescription(t('The remote ID of the Product.'))
    ->setSettings([
      'max_length' => 255,
      'text_processing' => 0,
    ])
    ->setDefaultValue('');
  $fields['source'] = BaseFieldDefinition::create('string')
    ->setLabel(t('Source'))
    ->setDescription(t('The source of the Product.'))
    ->setSettings([
      'max_length' => 255,
      'text_processing' => 0,
    ])
    ->setDefaultValue('');
  $fields['created'] = BaseFieldDefinition::create
    ('created')
    ->setLabel(t('Created'))
    ->setDescription(t('The time that the entity was
      created.'));
  $fields['changed'] = BaseFieldDefinition::create
  ('changed')
    ->setLabel(t('Changed'))
    ->setDescription(t('The time that the entity was last
      edited.'));
  return $fields;
}

First and foremost, we will need to inherit the base fields of the parent class. This includes things such as the ID and UUID fields.

Second, we define our own fields, starting with the product name field, which is of the string type. This string type is nothing more than a FieldType plugin, which I mentioned in the previous chapter. If you remember, this plugin extends a TypedData class itself. Apart from the obvious label and description, it has some settings, most notably a maximum length for the value, which is 255 characters. The view and form display options reference the FieldFormatter and FieldWidget plugins, respectively, responsible for how the field values should be rendered and input. Lastly, with the setDisplayConfigurable() method, we specify that some of the options on this field should be configurable through the UI.

Then, we have the number field, which is of the integer type and, for this example, is restricted to a number between 1 and 10,000. This restriction setting turns into a constraint under the hood. The rest of the options are similar to the name field.

Next, we have the remote_id string field, but it doesn’t have any widget or display settings because we don’t want to display or edit this value. It is mostly for internal use to keep track of the product ID of the remote source it came from. Similarly, the source string field is not displayed or configurable either because we want to use it to store the source of the product, where it has been imported from, and also to keep track of it programmatically.

Finally, the created and changed fields are special fields that store the timestamps for when the entity is created and modified. Not much more than that needs to be done because these field types automatically set the current timestamps as the field values.

By now, we can also see the rest of the class content, which is mostly made up of the methods required by our ProductInterface:

use EntityChangedTrait;
/**
 * {@inheritdoc}
 */
public function getName() {
  return $this->get('name')->value;
}
/**
 * {@inheritdoc}
 */
public function setName($name) {
  $this->set('name', $name);
  return $this;
}
/**
 * {@inheritdoc}
 */
public function getProductNumber() {
  return $this->get('number')->value;
}
/**
 * {@inheritdoc}
 */
public function setProductNumber($number) {
  $this->set('number', $number);
  return $this;
}
/**
 * {@inheritdoc}
 */
public function getRemoteId() {
  return $this->get('remote_id')->value;
}
/**
 * {@inheritdoc}
 */
public function setRemoteId($id) {
  $this->set('remote_id', $id);
  return $this;
}
/**
 * {@inheritdoc}
 */
public function getSource() {
  return $this->get('source')->value;
}
/**
 * {@inheritdoc}
 */
public function setSource($source) {
  $this->set('source', $source);
  return $this;
}
/**
 * {@inheritdoc}
 */
public function getCreatedTime() {
  return $this->get('created')->value;
}
/**
 * {@inheritdoc}
 */
public function setCreatedTime($timestamp) {
  $this->set('created', $timestamp);
  return $this;
}

As promised, we are making use of the EntityChangedTrait to handle the changed field and implement simple getters and setters for the values found in the fields we defined as base fields. If you remember the TypedData section, the way we access a value (since the cardinality is always 1 for these fields) is by running the following:

$this->get('field_name')->value

Before we finish off with our Product entity class, let’s ensure we use all the remaining classes at the top:

use DrupalCoreEntityEntityChangedTrait;
use DrupalCoreEntityEntityTypeInterface;
use DrupalCoreFieldBaseFieldDefinition;

Let’s now move through the entity type plugin annotation and create the handlers we’ve been referencing there. We can start with the list builder, which we can place at the root of our namespace:

namespace Drupalproducts;
use DrupalCoreEntityEntityInterface;
use DrupalCoreEntityEntityListBuilder;
/**
 * EntityListBuilderInterface implementation for the
     Product entities.
 */
class ProductListBuilder extends EntityListBuilder {
  /**
   * {@inheritdoc}
   */
  public function buildHeader() {
    $header['id'] = $this->t('Product ID');
    $header['name'] = $this->t('Name');
    return $header + parent::buildHeader();
  }
  /**
   * {@inheritdoc}
   */
  public function buildRow(EntityInterface $entity) {
    /* @var $entity DrupalproductsEntityProduct */
    $row['id'] = $entity->id();
    $row['name'] = $entity->toLink();
    return $row + parent::buildRow($entity);
  }
}

The purpose of this handler is to build the administration page that lists the available entities. On this page, we will then have some info about them, as well as operation links to edit and delete and whatever else we might need. For our products, we simply extend from the default EntityListBuilder class, but override the buildHeader() and builderRow() methods to add some information specific to our products. The names of these methods are self-explanatory, but one thing to keep in mind is that the keys from the $header array we return need to match the keys from the $row array we return. Also, of course, the arrays need to have the same number of records so that the table header matches the individual rows. If you look inside EntityListBuilder, you can note some other handy methods you might want to override, such as the one that builds the query and the one that loads the entities. For us, this is enough.

Our products list builder will have, for now, only two columns: the ID and the name. For the latter, each row will actually be a link to the product’s canonical URL (the main URL for this entity in Drupal). The way we built the link to the entity is by using the shorthand toLink() method, which does nothing more than create a Link object with the label and canonical URL of the entity.

Note

The construct for the entity canonical route is in the entity.[entity_type].canonical format. Other useful entity links can be built by replacing the word canonical with the keys from the links definition of the EntityType plugin annotation.

That is pretty much it for the list builder, and we can move on to the form handler.

Since creating and editing an entity share so much in terms of what we need in the form, we use the same ProductForm for both of those operations. Let’s create that form class now inside the Form directory of the module namespace:

namespace DrupalproductsForm;
use DrupalCoreEntityContentEntityForm;
use DrupalCoreFormFormStateInterface;
/**
 * Form for creating/editing Product entities.
 */
class ProductForm extends ContentEntityForm {
  /**
   * {@inheritdoc}
   */
  public function save(array $form, FormStateInterface
    $form_state) {
    $entity = $this->entity;
    $status = parent::save($form, $form_state);
    switch ($status) {
      case SAVED_NEW:
        $this->messenger()->addMessage($this->t('Created
          the %label Product.', [
          '%label' => $entity->label(),
        ]));
        break;
      default:
        $this->messenger()->addMessage($this->t('Saved the
          %label Product.', [
          '%label' => $entity->label(),
        ]));
    }
    $form_state->setRedirect('entity.product.canonical',
      ['product' => $entity->id()]);
  }
}

We extend ContentEntityForm, which is a specialized form class for content entities. It itself extends EntityForm, which then subclasses the FormBase we’ve already encountered in Chapter 2, Creating Your First Module. However, the former two give us a lot of the functionality needed to manage our entities without writing much code ourselves.

The only thing we actually want to do is override the save() method in order to write a message to the user, informing them that the product has either been created or updated. We know what happened because the EntityInterface::save() method returns a specific constant to denote the type of operation that occurred.

We also want to redirect to the canonical URL of the product entity when the save happens. This we do with a very handy method on the FormStateInterface object with which we can specify a route (and any necessary parameters), and it will make sure that when the form is submitted, the user will be redirected to that route. Neat, isn’t it?

Note

You can see I used the helper messenger() method from the parent class to print the message to the user. I did this on purpose to keep things short, but it is in fact used statically as it’s not been injected. As we saw in Chapter 2, Creating Your First Module, you should instead inject the Messenger service. Do refer back to that chapter for a recap on how to inject services if you are unsure.

As I mentioned, for the delete operation, we just use the ContentEntityDeleteForm, which does all we need: it presents a confirmation form where we submit and trigger the delete operation. This is a typical flow for deleting resources in Drupal. As we will see a bit later, for configuration entities, there will be some methods we will need to write ourselves for this same process to happen.

All our handlers are done now, and our product entity type is operational. However, in order to be able to work with it, let’s create some links in the admin menu to be able to easily manage them.

First, create the products.links.menu.yml file:

entity.product.collection:
  title: 'Product list'
  route_name: entity.product.collection
  description: 'List Product entities'
  parent: system.admin_structure
  weight: 100

This defines a menu link under the Structure link for the product list (the page built with our list builder handler, located at the entity.product.collection route).

Next, let’s create some local tasks (tabs) so that we get handy links on the product page to edit and delete the product entity. So, inside a products.links.task.yml file:

entity.product.canonical:
  route_name: entity.product.canonical
  base_route: entity.product.canonical
  title: 'View'
entity.product.edit_form:
  route_name: entity.product.edit_form
  base_route: entity.product.canonical
  title: 'Edit'
entity.product.delete_form:
  route_name: entity.product.delete_form
  base_route: entity.product.canonical
  title: Delete
  weight: 10

You remember this from Chapter 5, Menus and Menu Links, don’t you? The base route is always the canonical route for the entity, which essentially groups the tabs together. Then, the routes we use for the other two tasks are the edit_form and delete_form links of the entity type. You can refer to the links section of the Entity type plugin annotation to understand where these come from. The reason we don’t need to specify any parameters here (since those routes do require a product ID) is because the base route has that parameter in the URL already. So, the tasks will use that one. And this is very handy.

Finally, we also want an action link to create a new product entity, which will be on the product list page. So, inside the products.links.action.yml file:

entity.product.add_form:
  route_name: entity.product.add_form
  title: 'Add Product'
  appears_on:
    - entity.product.collection

Again, none of this should be new, as we covered it in detail in Chapter 5, Menus and Menu Links. We are finally done. Enable the module and you can play with the products.

If the products module was enabled on your site before writing all the entity code, please read the next subsection on performing entity updates.

To play with the products, we can go to admin/structure/product and take a look at our (empty) product entity list:

Figure 7.1: Our product entity list

Figure 7.1: Our product entity list

We can create new products, edit them, and finally, delete them. Remember, due to our field configuration, the manual product creation/edit does not permit the remote_id and source fields to be managed. For our purpose, we want those to only be programmatically available since any manual products will be considered as not needing that data. For example, if we wanted to make the source field show up as a form widget, all we would have to do is change its base field definition to this:

$fields['source'] = BaseFieldDefinition::create('string')
  ->setLabel(t('Source'))
  ->setDescription(t('The source of the Product.'))
  ->setSettings([
    'max_length' => 255,
    'text_processing' => 0,
  ])
  ->setDefaultValue('')
  ->setDisplayOptions('form', [
    'type' => 'string_textfield',
    'weight' => -4,
  ]);

Also, we’d need to clear the cache. This would make the form element for the source field show up, but the value would still not be displayed on the canonical page of the entity because we have not set any view display options. In other words, we have not chosen a formatter.

However, in our case, the product entity is ready to store data, and all the TypedData APIs we practiced in the previous chapter with the Node entity type will work just as well with this one. So, we can now turn to writing our importer logic to get some remote products onto our website. But not before talking for a bit about entity updates.

Entity updates

Whenever we create an entity type or add a base field to an existing one, we need Drupal to run some updates so that the relevant database tables get created. The only way we can get away with not doing this is if the module where we define these is not yet installed. Because all tables get created upon module installation.

Otherwise, there are two ways to do this.

Development

The first way is intended solely for development. This means that you should only use it when iterating over your work locally, before deploying your application somewhere else. And the way to do it is by using the entity-updates command of the devel_entity_updates contributed module. This command will compare the entity type definitions (the plugin) to the state of the database tables and perform the updates. Do keep in mind that it won’t always work, depending on what kind of data you have already stored. For example, if you have entities, it won’t delete the tables.

Production

The second way is to write the updates ourselves inside hook_update_N(). We will talk more about this hook later, but inside such a hook we can instruct Drupal to update the tables in order to match the definitions. And we can do so quite granularly. We are not going to get into details on all the types of updates that can be made because there are plenty. Instead, we will cover a simple example of how we could have installed our Product entity type using this approach:

function products_update_10000(&$sandbox) {
  Drupal::entityTypeManager()->clearCachedDefinitions();
  $entity_type = Drupal::entityTypeManager()->
    getDefinition('product');
  Drupal::entityDefinitionUpdateManager()->
    installEntityType($entity_type);
}

For the time being, don’t worry too much about the function this goes into. We will get back to this. But essentially this is the code that would install the Product entity type had our module been already installed on the site before us creating its definition in code. So, what happens here?

We first clear the cached definitions of the entity types defined in code to ensure our new Product definition is picked up. Then, we load this definition (the plugin basically) and pass that to the entity definition update manager. This is the service that has all the methods we need in order to perform updates to the entity type definitions: install, uninstall, create/update fields, and so on. I encourage you to check it out for more information: DrupalCoreEntityEntityDefinitionUpdateManagerInterface.

For now, since we are still developing, we will use the development approach because it’s much faster and we do not have our site installed anywhere else.

Our custom plugin type

Since pretty much the second page of this book, you’ve been reading about how important plugins are and how widely they are used in Drupal. I have backed that claim with references to “this or that” being a plugin in basically every chapter. However, I have not really explained how you can create your own custom plugin type. Now, since our importer logic is a perfect candidate for plugins, I will do so here, and to exemplify the theory, we will implement an Importer plugin type.

The very first thing a plugin type needs is a manager service. This is responsible for bringing together two critical aspects of plugins (but not only): discovery and factory (instantiation). For these two tasks, it delegates to specialized objects. The most common method of discovery is through annotations (AnnotatedClassDiscovery), and the most common factory is the container-aware one—ContainerFactory. So, essentially, the manager is the central player that finds and processes all the plugin definitions and instantiates plugins. Also, it does so with the help of those other guys.

Many plugin types in Drupal, since they follow the defaults I mentioned before, use the DefaultPluginManager, or I should say, they extend this class. It provides them with the annotated discovery and container-aware factory. So, that is what we will do as well and see how simple it is to create a plugin type manager.

Typically, it lives in the Plugin namespace of the module, so ours can look like this:

namespace DrupalproductsPlugin;
use DrupalCorePluginDefaultPluginManager;
use DrupalCoreCacheCacheBackendInterface;
use DrupalCoreExtensionModuleHandlerInterface;
/**
 * Provides the Importer plugin manager.
 */
class ImporterManager extends DefaultPluginManager {
  /**
   * ImporterManager constructor.
   *
   * @param Traversable $namespaces
   *   An object that implements Traversable which
         contains the root paths
   *   keyed by the corresponding namespace to look for
         plugin implementations.
   * @param DrupalCoreCacheCacheBackendInterface
       $cache_backend
   *   Cache backend instance to use.
   * @param DrupalCoreExtensionModuleHandlerInterface
       $module_handler
   *   The module handler to invoke the alter hook with.
   */
  public function __construct(Traversable $namespaces,
    CacheBackendInterface $cache_backend,
      ModuleHandlerInterface $module_handler) {
    parent::__construct('Plugin/Importer', $namespaces,
      $module_handler, 'DrupalproductsPlugin
        ImporterPluginInterface', 'Drupalproducts
          AnnotationImporter');
    $this->alterInfo('products_importer_info');
    $this->setCacheBackend($cache_backend,
      'products_importer_plugins');
  }
}

Aside from extending the DefaultPluginManager, we will need to override the constructor and re-call the parent constructor with some parameters specific to our plugins. This is the most important part, and in order, these are the following (omitting the ones that are simply passed through):

  • The relative namespace where plugins of this type will be found—in this case, in the Plugin/Importer folder
  • The interface each plugin of this type needs to implement—in our case, DrupalproductsPluginImporterPluginInterface (which we have to create)
  • The annotation class used by our plugin type (the one whose class properties map to the possible annotation properties found in the DocBlock above the plugin class)—in our case, DrupalproductsAnnotationImporter (which we have to create)

In addition to calling the parent constructor with these options, we will need to provide the “alter” hook for the available definitions. This will make it possible for other modules to implement this hook and alter the found plugin definitions. The resulting hook in our case is hook_products_importer_info_alter.

Lastly, we also provide a specific cache key for the backend responsible for caching the plugin definitions. This is for increased performance: as you should already know by now, creating a new plugin requires clearing the cache.

That’s it for our manager. However, since this is a service, we will need to register it as such inside the products.services.yml file:

services:
  products.importer_manager:
    class: DrupalproductsPluginImporterManager
    parent: default_plugin_manager

As you can see, we inherit the dependencies (arguments) from the default_plugin_manager service instead of duplicating them here again. If you remember from Chapter 3, Logging and Mailing, this is a neat little trick we can do.

Now, since we referenced some classes in the manager, we will need to create them. Let’s start with the annotation class:

namespace DrupalproductsAnnotation;
use DrupalComponentAnnotationPlugin;
/**
 * Defines an Importer item annotation object.
 *
 * @see DrupalproductsPluginImporterManager
 *
 * @Annotation
 */
class Importer extends Plugin {
  /**
   * The plugin ID.
   *
   * @var string
   */
  public $id;
  /**
   * The label of the plugin.
   *
   * @var DrupalCoreAnnotationTranslation
   *
   * @ingroup plugin_translatable
   */
  public $label;
}

This class needs to extend DrupalComponentAnnotationPlugin, which is the base class for annotations and already implements AnnotationInterface.

For our purpose, we keep it simple. All we need is a plugin ID and a label. If we wanted to, we could add more properties to this class and describe them. It’s standard practice to do so because otherwise there is no clear way to know which properties a plugin annotation can contain.

Next, let’s also write the interface the plugins are required to implement:

namespace DrupalproductsPlugin;
use DrupalComponentPluginPluginInspectionInterface;
/**
 * Defines an interface for Importer plugins.
 */
interface ImporterPluginInterface extends
  PluginInspectionInterface {
  /**
   * Performs the import.
   *
   * Returns TRUE if the import was successful or FALSE
       otherwise.
   *
   * @return bool
   */
  public function import();
}

Again, we keep it simple. For now, our importer will have only one method specific to it: import(). However, it will have other methods specific to plugins, which can be found in the PluginInspectionInterface we are extending. These are getPluginId() and getPluginDefinition() and are also quite important as the system expects to be able to get this info from the plugins.

Next, plugins of any type need to extend PluginBase because it contains a host of mandatory implemented methods (such as the ones I mentioned before). However, it is also best practice for the module that introduces a plugin type to also provide a base plugin class that plugins can extend. Its goal is to extend PluginBase and also provide all the necessary logic needed by all the plugins of this type. For example, when we create a new block, we extend BlockBase, which, somewhere down the line, extends PluginBase.

In our case, this base (abstract) class can look something like this:

namespace DrupalproductsPlugin;
use DrupalComponentPluginExceptionPluginException;
use DrupalComponentPluginPluginBase;
use DrupalCoreEntityEntityTypeManager;
use DrupalCorePluginContainerFactoryPluginInterface;
use DrupalproductsEntityImporterInterface;
use GuzzleHttpClient;
use SymfonyComponentDependencyInjection
  ContainerInterface;
/**
 * Base class for Importer plugins.
 */
abstract class ImporterBase extends PluginBase implements
  ImporterPluginInterface, ContainerFactoryPluginInterface {
  /**
   * @var DrupalCoreEntityEntityTypeManager
   */
  protected $entityTypeManager;
  /**
   * @var GuzzleHttpClient
   */
  protected $httpClient;
  /**
   * {@inheritdoc}
   */
  public function __construct(array $configuration,
    $plugin_id, $plugin_definition, EntityTypeManager
      $entityTypeManager, Client $httpClient) {
    parent::__construct($configuration, $plugin_id,
      $plugin_definition);
    $this->entityTypeManager = $entityTypeManager;
    $this->httpClient = $httpClient;
    if (!isset($configuration['config'])) {
      throw new PluginException('Missing Importer
        configuration.');
    }
    if (!$configuration['config'] instanceof
      ImporterInterface) {
      throw new PluginException('Wrong Importer
        configuration.');
    }
  }
  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface
    $container, array $configuration, $plugin_id,
      $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('entity_type.manager'),
      $container->get('http_client')
    );
  }
}

We implement ImporterPluginInterface to require subclasses to have the import() method. However, we also make the plugins container aware and already inject some helpful services. One is the EntityTypeManager because we expect all importers to need it. The other is the Guzzle HTTP client that we use in Drupal to make PSR-7 requests to external resources.

Adding this here is a judgment call. We can imagine more than one plugin needing external requests, but if it turns out they don’t, we should surely remove it and add it only to that specific plugin. The opposite also holds true. If in the third plugin implementation we identify another common service, we can remove it from the plugins and inject it here. All while watching out for backward compatibility.

Before talking about those exceptions we’re throwing in the constructor, it’s important to know how the plugin manager creates a new instance of a plugin. It uses its createInstance() method, which takes a plugin ID as the first parameter and an optional array of plugin configuration as a second parameter. The relevant factory then passes that array of configuration to the plugin constructor itself as the second parameter. Oftentimes, this is empty. However, for our plugin type, we will need configuration to be passed to the plugin in the form of a configuration entity (which we have to create next). Without such an entity, we want the plugins to fail because they cannot work without the instructions found in this entity. So, in the constructor, we check whether $configuration['config'] is an instance of DrupalproductsEntityImporterInterface, which will be the interface our configuration entity will implement (we don’t have it yet). Otherwise, we throw the exception because this plugin cannot work without it.

Our plugin type is complete for now. Obviously, we don’t have any plugins yet, and before we create one, let’s create the Importer configuration entity type.

Our custom configuration entity type

If you remember NodeType from the previous chapter, you know the essentials of creating custom configuration entity types. So, let’s create our Importer type now. Like before, we start with the annotation part, which this time is a ConfigEntityType:

namespace DrupalproductsEntity;
use DrupalCoreConfigEntityConfigEntityBase;
/**
 * Defines the Importer entity.
 *
 * @ConfigEntityType(
 *   id = "importer",
 *   label = @Translation("Importer"),
 *   handlers = {
 *     "list_builder" = "Drupalproducts
         ImporterListBuilder",
 *     "form" = {
 *       "add" = "DrupalproductsFormImporterForm",
 *       "edit" = "DrupalproductsFormImporterForm",
 *       "delete" = "DrupalproductsForm
           ImporterDeleteForm"
 *     },
 *     "route_provider" = {
 *       "html" = "DrupalCoreEntityRouting
           AdminHtmlRouteProvider",
 *     },
 *   },
 *   config_prefix = "importer",
 *   admin_permission = "administer site configuration",
 *   entity_keys = {
 *     "id" = "id",
 *     "label" = "label",
 *     "uuid" = "uuid"
 *   },
 *   links = {
 *     "add-form" = "/admin/structure/importer/add",
 *     "edit-form" = "/admin/structure/importer/{importer}/
         edit",
 *     "delete-form" = "/admin/structure/importer/
         {importer}/delete",
 *     "collection" = "/admin/structure/importer"
 *   },
 *   config_export = {
 *     "id",
 *     "label",
 *     "url",
 *     "plugin",
 *     "update_existing",
 *     "source"
 *   }
 * )
 */
class Importer extends ConfigEntityBase implements
  ImporterInterface {}

As with the Product entity, we will need to create a list builder handler, as well as form handlers. In this case, though, we also need to create a form handler for the delete operation—we will soon see why. Finally, since we have a configuration entity, we also specify the config_export and config_prefix keys to be used for exporting. If you remember from the previous chapter, the first one denotes the names of the fields that should be persisted (we’ll see them in a minute), while the second denotes the prefix the configuration names should get when stored. One thing you’ll note is that we don’t have a canonical link because we don’t really need one—our entities don’t need a detail page, hence no canonical link to it needs to be defined.

Now, it’s time to create the ImporterInterface that the entities implement:

namespace DrupalproductsEntity;
use DrupalCoreConfigEntityConfigEntityInterface;
use DrupalCoreUrl;
/**
 * Importer configuration entity.
 */
interface ImporterInterface extends ConfigEntityInterface {
  /**
   * Returns the Url where the import can get the data
     from.
   *
   * @return Url
   */
  public function getUrl();
  /**
   * Returns the Importer plugin ID to be used by this
     importer.
   *
   * @return string
   */
  public function getPluginId();
  /**
   * Whether or not to update existing products if they
     have already been imported.
   *
   * @return bool
   */
  public function updateExisting();
  /**
   * Returns the source of the products.
   *
   * @return string
   */
  public function getSource();
}

In these configuration entities, we want to store, for now, a URL to the resource where the products can be retrieved from, the ID of the importer plugin to use, whether we want existing products to be updated if they had already been imported, and the source of the products. For all these fields, we create some getter methods. You’ll note that getUrl() needs to return a Url instance. Again, we create a well-defined interface for the public API of the entity type as we did with the product entity type.

And this is what the Importer class body that implements this interface looks like:

/**
 * The Importer ID.
 *
 * @var string
 */
protected $id;
/**
 * The Importer label.
 *
 * @var string
 */
protected $label;
/**
 * The URL from where the import file can be retrieved.
 *
 * @var string
 */
protected $url;
/**
 * The plugin ID of the plugin to be used for processing
   this import.
 *
 * @var string
 */
protected $plugin;
/**
 * Whether or not to update existing products if they have
   already been imported.
 *
 * @var bool
 */
protected $update_existing = TRUE;
/**
 * The source of the products.
 *
 * @var string
 */
protected $source;
/**
 * {@inheritdoc}
 */
public function getUrl() {
  return $this->url ? Url::fromUri($this->url) : NULL;
}
/**
 * {@inheritdoc}
 */
public function getPluginId() {
  return $this->plugin;
}
/**
 * {@inheritdoc}
 */
public function updateExisting() {
  return $this->update_existing;
}
/**
 * {@inheritdoc}
 */
public function getSource() {
  return $this->source;
}

If you remember from the previous chapter, defining fields on a configuration entity type is as simple as defining properties on the class itself. Then, the interface methods are implemented next, and there is no rocket science involved in that. The getUrl() method, as expected, will try to create an instance of Url from the value.

Let’s not forget the use statement for it at the top:

use DrupalCoreUrl;

Since we are talking about configuration here, we need a schema, so let’s define that as well. If you remember, it goes inside the config/schema folder of our module in a *.schema.yml file. This can be named after the module and contains the schema definitions of all configurations of the module. Alternatively, it can be named after the individual configuration entity type, so, in our case, importer.schema.yml (to keep things neatly organized):

products.importer.*:
  type: config_entity
  label: 'Importer config'
  mapping:
   id:
     type: string
     label: 'ID'
   label:
     type: label
     label: 'Label'
   url:
     type: uri
     label: Uri
   plugin:
     type: string
     label: Plugin ID
   update_existing:
     type: boolean
     label: Whether to update existing products
   source:
     type: string
     label: The source of the products

If you recall, the wildcard is used to apply the schema to all configuration items that match the prefix. So, in our case, it will match all the importer configuration entities. And the individual field definition should be easy to understand from the lessons of the previous chapter.

Now, let’s go ahead and create the list builder handler that will take care of the admin entity listing:

namespace Drupalproducts;
use DrupalCoreConfigEntityConfigEntityListBuilder;
use DrupalCoreEntityEntityInterface;
/**
 * Provides a listing of Importer entities.
 */
class ImporterListBuilder extends ConfigEntityListBuilder {
  /**
   * {@inheritdoc}
   */
  public function buildHeader() {
    $header['label'] = $this->t('Importer');
    $header['id'] = $this->t('Machine name');
    return $header + parent::buildHeader();
  }
  /**
   * {@inheritdoc}
   */
  public function buildRow(EntityInterface $entity) {
    $row['label'] = $entity->label();
    $row['id'] = $entity->id();
    return $row + parent::buildRow($entity);
  }
}

This time we are extending the ConfigEntityListBuilder, which provides some functionalities specific to configuration entities. However, we are essentially doing the same as with the products listing—setting up the table header and the individual row data, nothing major. I recommend that you inspect ConfigEntityListBuilder and see what else you can do in the subclass.

Now, we can finally take care of the form handler and start with the default create/edit form:

namespace DrupalproductsForm;
use DrupalCoreEntityEntityForm;
use DrupalCoreFormFormStateInterface;
use DrupalCoreMessengerMessengerInterface;
use DrupalCoreUrl;
use DrupalproductsPluginImporterManager;
use SymfonyComponentDependencyInjection
  ContainerInterface;
/**
 * Form for creating/editing Importer entities.
 */
class ImporterForm extends EntityForm {
  /**
   * @var DrupalproductsPluginImporterManager
   */
  protected $importerManager;
  /**
   * ImporterForm constructor.
   *
   * @param DrupalproductsPluginImporterManager
       $importerManager
   * @param DrupalCoreMessengerMessengerInterface
       $messenger
   */
  public function __construct(ImporterManager
    $importerManager, MessengerInterface $messenger) {
    $this->importerManager = $importerManager;
    $this->messenger = $messenger;
  }
  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface
    $container) {
    return new static(
      $container->get('products.importer_manager'),
      $container->get('messenger')
    );
  }
  /**
   * {@inheritdoc}
   */
  public function form(array $form, FormStateInterface
    $form_state) {
    $form = parent::form($form, $form_state);
    /** @var DrupalproductsEntityImporterInterface
      $importer */
    $importer = $this->entity;
    $form['label'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Name'),
      '#maxlength' => 255,
      '#default_value' => $importer->label(),
      '#description' => $this->t('Name of the Importer.'),
      '#required' => TRUE,
    ];
    $form['id'] = [
      '#type' => 'machine_name',
      '#default_value' => $importer->id(),
      '#machine_name' => [
        'exists' => 'DrupalproductsEntity
          Importer::load',
      ],
      '#disabled' => !$importer->isNew(),
    ];
    $form['url'] = [
      '#type' => 'url',
      '#default_value' => $importer->getUrl() instanceof
        Url ? $importer->getUrl()->toString() : '',
      '#title' => $this->t('Url'),
      '#description' => $this->t('The URL to the import
       resource'),
      '#required' => TRUE,
    ];
    $definitions = $this->importerManager->
      getDefinitions();
    $options = [];
    foreach ($definitions as $id => $definition) {
      $options[$id] = $definition['label'];
    }
    $form['plugin'] = [
      '#type' => 'select',
      '#title' => $this->t('Plugin'),
      '#default_value' => $importer->getPluginId(),
      '#options' => $options,
      '#description' => $this->t('The plugin to be used
        with this importer.'),
      '#required' => TRUE,
    ];
    $form['update_existing'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Update existing'),
      '#description' => $this->t('Whether to update
        existing products if already imported.'),
      '#default_value' => $importer->updateExisting(),
    ];
    $form['source'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Source'),
      '#description' => $this->t('The source of the
        products.'),
      '#default_value' => $importer->getSource(),
    ];
    return $form;
  }
  /**
   * {@inheritdoc}
   */
  public function save(array $form, FormStateInterface
    $form_state) {
    /** @var DrupalproductsEntityImporter $importer */
    $importer = $this->entity;
    $status = $importer->save();
    switch ($status) {
      case SAVED_NEW:
        $this->messenger->addMessage($this->t('Created the
          %label Importer.', [
          '%label' => $importer->label(),
        ]));
        break;
      default:
        $this->messenger->addMessage($this->t('Saved the
          %label Importer.', [
          '%label' => $importer->label(),
        ]));
    }
    $form_state->setRedirectUrl($importer->toUrl
      ('collection'));
  }
}

We are directly extending EntityForm in this case because configuration entities don’t have a specific form class like content entities do. For this reason, we also have to implement the form elements for all our fields inside the form() method.

But first things first. We know we want the configuration entity to select a plugin to use, so, for this reason, we inject the ImporterManager we created earlier. We will use it to get all the existing definitions. And we also inject the Messenger service to use it later for printing a message to the user.

Inside the form() method, we define all the form elements for the fields. We use a textfield for the label and a machine_name field for the ID of the entity. The latter is a special JavaScript-powered field that derives its value from a “source” field (which defaults to the field label if one is not specified). It is also disabled if we are editing the form, and is using a dynamic callback to try to load an entity by the provided ID, failing validation if it exists already. This is useful to ensure that IDs are not duplicated.

Next, we have a url form element, which does some URL-specific validation and handling to ensure that a proper URL is added. Then, we create an array of select element options of all the available importer plugin definitions. For this, we use the plugin manager’s getDefinitions(), from which we can get the IDs and labels. A plugin definition is an array that primarily contains the data found in the annotation and some other data processed and added by the manager (in our case, only defaults). At this stage, our plugins are not yet instantiated. And we use those options on the select list.

Finally, we have the simple checkbox and textfield elements for the last two fields, as we want to store the update_existing field as a Boolean and the source as a string.

The save() method is pretty much like it was in the Product entity form; we are simply displaying a message and redirecting the user to the entity listing page (using the handy toUrl() method on the entity to build the URL). Since we named the form elements exactly the same as the fields, we don’t need to do any mapping of the form values to the field names. That is taken care of.

Let’s now write the delete form handler:

namespace DrupalproductsForm;
use DrupalCoreEntityEntityConfirmFormBase;
use DrupalCoreFormFormStateInterface;
use DrupalCoreMessengerMessengerInterface;
use DrupalCoreUrl;
use SymfonyComponentDependencyInjection
  ContainerInterface;
/**
 * Form for deleting Importer entities.
 */
class ImporterDeleteForm extends EntityConfirmFormBase {
  /**
   * ImporterDeleteForm constructor.
   *
   * @param DrupalCoreMessengerMessengerInterface
     $messenger
   */
  public function __construct(MessengerInterface
    $messenger) {
    $this->messenger = $messenger;
  }
  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface
    $container) {
    return new static(
      $container->get('messenger')
    );
  }
  /**
   * {@inheritdoc}
   */
  public function getQuestion() {
    return $this->t('Are you sure you want to delete
      %name?', ['%name' => $this->entity->label()]);
  }
  /**
   * {@inheritdoc}
   */
  public function getCancelUrl() {
    return new Url('entity.importer.collection');
  }
  /**
   * {@inheritdoc}
   */
  public function getConfirmText() {
    return $this->t('Delete');
  }
  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form,
    FormStateInterface $form_state) {
    $this->entity->delete();
    $this->messenger->addMessage($this->t('Deleted @entity
      importer.', ['@entity' => $this->entity->label()]));
    $form_state->setRedirectUrl($this->getCancelUrl());
  }
}

As I mentioned earlier, for configuration entities, we will need to implement this form handler ourselves. However, it’s not a big deal because we can extend EntityConfirmFormBase and just implement some simple methods:

  • In getQuestion() we return the string to be used for the question on the confirmation form.
  • In getConfirmText() we return the label of the delete button.
  • In getCancelUrl() we provide the redirect URL for the user after either a cancellation or a successful delete.
  • In submitForm() we delete the entity, print a success message, and redirect to the URL we set in getCancelUrl().

And with this, we are done with our configuration entity type. The last thing we might want to do is create some menu links to be able to navigate to the relevant pages (the same as we did for the product entity type). For the entity list page, we can have this in our products.links.menu.yml file:

entity.importer.collection:
  title: 'Importer list'
  route_name: entity.importer.collection
  description: 'List Importer entities'
  parent: system.admin_structure
  weight: 99

There’s nothing new here. We can also create the action link to add a new entity inside the products.links.action.yml file:

entity.importer.add_form:
  route_name: 'entity.importer.add_form'
  title: 'Add Importer'
  appears_on:
    - entity.importer.collection

We do the same thing here as we did with the products. However, we won’t create local tasks because we don’t have a canonical route for the configuration entities, so we don’t really need them.

Now, if we clear our cache and go to admin/structure/importer, we should see the empty importer entity listing:

Figure 7.2: Importer Entity list

Figure 7.2: Importer Entity list

We can access the form to create a new Importer entity, but we cannot yet save it because there are no Importer plugins in the system for the Plugin field. We will deal with these next.

The Importer plugin

Alright, since all of our setup is in place, we can now go ahead and create our first importer plugin. As we defined it in the manager, these plugins need to go in the Plugin/Importer namespace of modules. So, let’s start with a simple JsonImporter, which will use a remote URL resource to import products. This is an example JSON file that will be processed by this plugin, just for testing purposes:

{
   "products" : [
     {
       "id" : 1,
       "name": "TV",
       "number": 341
     },
     {
       "id" : 2,
       "name": "VCR",
       "number": 123
     },
     {
       "id" : 3,
       "name": "Stereo",
       "number": 234
     }
   ]
 }

I know, VCR, right? We have an ID, a name, and a product number. This is all totally made-up information about products just to illustrate the process. So, let’s create our JsonImporter plugin:

namespace DrupalproductsPluginImporter;
use DrupalproductsPluginImporterBase;
/**
 * Product importer from a JSON format.
 *
 * @Importer(
 *   id = "json",
 *   label = @Translation("JSON Importer")
 * )
 */
class JsonImporter extends ImporterBase {
  /**
   * {@inheritdoc}
   */
  public function import() {
    $data = $this->getData();
    if (!$data) {
      return FALSE;
    }
    if (!isset($data->products)) {
      return FALSE;
    }
    $products = $data->products;
    foreach ($products as $product) {
      $this->persistProduct($product);
    }
    return TRUE;
  }
  /**
   * Loads the product data from the remote URL.
   *
   * @return object
   */
  private function getData() {
    /** @var DrupalproductsEntityImporterInterface
      $config */
    $config = $this->configuration['config'];
    $request = $this->httpClient->get($config->getUrl()->
      toString());
    $string = $request->getBody()->getContents();
    return json_decode($string);
  }
  /**
   * Saves a Product entity from the remote data.
   *
   * @param object $data
   */
  private function persistProduct($data) {
    /** @var DrupalproductsEntityImporterInterface
      $config */
    $config = $this->configuration['config'];
    $existing = $this->entityTypeManager->getStorage
      ('product')->loadByProperties(['remote_id' => $data
        ->id, 'source' => $config->getSource()]);
    if (!$existing) {
      $values = [
        'remote_id' => $data->id,
        'source' => $config->getSource()
      ];
      /** @var DrupalproductsEntityProductInterface
        $product */
      $product = $this->entityTypeManager->getStorage
        ('product')->create($values);
      $product->setName($data->name);
      $product->setProductNumber($data->number);
      $product->save();
      return;
    }
    if (!$config->updateExisting()) {
      return;
    }
    /** @var DrupalproductsEntityProductInterface
      $product */
    $product = reset($existing);
    $product->setName($data->name);
    $product->setProductNumber($data->number);
    $product->save();
  }
}

You can immediately spot the plugin annotation where we specify an ID and a label. Next, by extending ImporterBase, we inherit the dependent services and ensure that the required interface is implemented. Speaking of which, we basically only have to implement the import() method. So, let’s break down what we are doing:

  1. Inside the getData() method, we retrieve the product information from the remote resource. We do so by getting the URL from the Importer configuration entity and using Guzzle to make a request to that URL. We expect that to be JSON, so we just decode it as such. Of course, error handling is virtually nonexistent in this example, and that is not good.
  2. We loop through the resulting product data and call the persistProduct() method on each item. In there, we first check whether we already have the product entity. We do so using the simple loadByProperties() method on the product entity storage and try to find products that have the specific source and remote ID. If one doesn’t exist, we create it. This should all be familiar from the previous chapter when we looked at manipulating entities. If the product already exists, we first check whether, according to the configuration, we can update it, and only do so if that allows us to. The loadByProperties() method always returns an array of entities, but since we only expect to have a single product with the same remote ID and source combination, we simply reset this array to get to that one entity. Then, we just set the name and product number onto the entity.

As you can see, instead of using the Entity API/TypedData set() method to update the entity field values, we use our own interface methods. I find that this is a much cleaner, more modern, and IDE-friendly way because everything is very explicit.

One thing you might notice is the error handling in this import process, or more precisely, a lack thereof. This is because I kept things simple for the purpose of focusing on the current topic. Normally, you would want to maybe throw and catch some exceptions and definitely log some messages (both error and success). You know how to do the latter from Chapter 3, Logging and Mailing.

And that is pretty much it. We can now create our first importer entity and make it use this importer plugin (after clearing the cache of course):

Figure 7.3: Creating an importer

Figure 7.3: Creating an importer

The URL in the previous screenshot is a random URL where the example JSON file is found (you should use your own), and we can see the only plugin available to choose, as well as the other entity fields we created form elements for. By saving this new entity, we can make use of it programmatically (assuming that the products.json file referenced in the URL exists):

$config = Drupal::entityTypeManager()->getStorage
  ('importer')->load('json');
$plugin = Drupal::service('products.importer_manager')
  ->createInstance($config->getPluginId(), ['config' =>
    $config]);
$plugin->import();

We first load the importer entity by ID. Then, we use the ImporterManager service to create a new instance of a plugin using the createInstance() method. Only one parameter is required for it—the ID of the plugin—but as I said earlier, we want to pass the configuration entity to it because it depends on it. So we do just that. Then, we call the import() method on the plugin. After running this code, the product entity listing will show some shiny new products.

Let’s, however, improve things a bit. Since the configuration entities and plugins are so tightly connected, let’s use the plugin manager to do this entire thing rather than having to first load an entity and request the plugin from it. In other words, let’s add a method to the plugin manager where we can pass the configuration entity ID, and it returns an instance of the relevant plugin; something like this:

public function createInstanceFromConfig($id) {
  $config = $this->entityTypeManager->getStorage
    ('importer')->load($id);
  if (!$config instanceof DrupalproductsEntity
    ImporterInterface) {
    return NULL;
  }
  return $this->createInstance($config->getPluginId(),
    ['config' => $config]);
}

Here, we essentially do the same thing as before, but we return NULL if there is no configuration entity found. You can choose to throw an exception if you want instead. However, as you may have correctly noticed, we also need to inject the EntityTypeManager into this class, so our constructor changes as well to take it as a last parameter and set it as a class property. You should be able to do that on your own. But we also need to alter the service definition for the plugin manager to add the EntityTypeManager as a dependency:

products.importer_manager:
  class: DrupalproductsPluginImporterManager
  parent: default_plugin_manager
  arguments: ['@entity_type.manager']

As you can see, we keep the parent inheritance key so that all the parent arguments are taken in. On top, however, we add our own regular arguments key, which will append arguments to the ones that come from the parent.

And with this we have simplified things for the client code:

$plugin = Drupal::service('products.importer_manager')
  ->createInstanceFromConfig('my_json_product_importer');
$plugin->import();

All we have to interact with is the plugin manager, and we can directly run the import. This is in some ways better because our configuration entities are not something we designed for being used by anyone else. They are simple configuration storage used by our importer plugins.

Content entity bundles

We have written a neat little piece of functionality. There are still improvements that we can and will make, but those are for later chapters when we cover other topics that we will need to learn about. Now, however, let’s take a step back to our content entity type and extend our products a bit by enabling bundles. We want to have more than one type of product that can be imported. And this will be a bundle that will be an option to choose when creating an Importer configuration. However, first, let’s make the product entity type “bundleable.”

We start by adjusting our Product entity plugin annotation:

/**
 * Defines the Product entity.
 *
 * @ContentEntityType(
 *   ...
 *   label = @Translation("Product"),
 *   bundle_label = @Translation("Product type"),
 *   handlers = {
 *   ...
 *   entity_keys = {
 *     ...
 *     "bundle" = "type",
 *   },
 *   ...
 *   bundle_entity_type = "product_type",
 *   field_ui_base_route = "entity.product_type.edit_form"
 * )
 */

We add a bundle_label for our bundle, an entity key for it that will map to the type field, the bundle_entity_type reference to the configuration entity type that will act as a bundle for the products, and a field_ui_base_route. This latter option is something we could have added before but was not necessary. Now, we can (and should) add it because we need a route where we can configure our product entities from the point of view of managing UI fields and the bundles. We’ll see these a bit later on.

Moreover, we also need to change something about the links. First, we will need to alter the add-form link:

"add-form" = "/admin/structure/product/add/
  {product_type}",

This will now take a product type in the URL to know which bundle we are creating. If you remember from the previous chapter when we were creating entities programmatically, the bundle is a required value from the beginning if the entity type has bundles.

Then, we add a new link, as follows:

"add-page" = "/admin/structure/product/add",

This will go to the initial add-form path but will list options of available bundles to select for creating a new product. Clicking on one of those will take us to the add-form link.

Since we made these changes, we also need to make a quick alteration to the product entity action link to use add-page instead of the add-form route:

entity.product.add_page:
  route_name: entity.product.add_page
  title: 'Add Product'
  appears_on:
    - entity.product.collection

This is required because, on the product entity list page (the collection URL), we don’t have a product type in context, so we cannot build a path to add-form; nor would it be logical to do so as we don’t know what type of product the user wants to create. As a quick bonus, if there is only one bundle, Drupal will redirect the user to the add-form link of that particular bundle.

The good thing is that since we specified an entity key for the bundle, we don’t have to define the field that will reference the bundle configuration entity. It will be done for us by the parent, ContentEntityType::baseFieldDefinitions(). So, what is left to do is to create the ProductType configuration entity type that will serve as product bundles. We already know more or less how this works. Inside our Entity namespace, we start our class like so:

namespace DrupalproductsEntity;
use DrupalCoreConfigEntityConfigEntityBundleBase;
/**
 * Product type configuration entity type.
 *
 * @ConfigEntityType(
 *   id = "product_type",
 *   label = @Translation("Product type"),
 *   handlers = {
 *     "list_builder" = "Drupalproducts
         ProductTypeListBuilder",
 *     "form" = {
 *       "add" = "DrupalproductsFormProductTypeForm",
 *       "edit" = "DrupalproductsFormProductTypeForm",
 *       "delete" = "DrupalproductsForm
           ProductTypeDeleteForm"
 *     },
 *     "route_provider" = {
 *       "html" = "DrupalCoreEntityRouting
           AdminHtmlRouteProvider",
 *     },
 *   },
 *   config_prefix = "product_type",
 *   admin_permission = "administer site configuration",
 *   bundle_of = "product",
 *   entity_keys = {
 *     "id" = "id",
 *     "label" = "label",
 *     "uuid" = "uuid"
 *   },
 *   links = {
 *     "canonical" = "/admin/structure/product_type/
         {product_type}",
 *     "add-form" = "/admin/structure/product_type/add",
 *     "edit-form" = "/admin/structure/product_type/
         {product_type}/edit",
 *     "delete-form" = "/admin/structure/product_type/
         {product_type}/delete",
 *     "collection" = "/admin/structure/product_type"
 *   },
 *   config_export = {
 *     "id",
 *     "label"
 *   }
 * )
 */
class ProductType extends ConfigEntityBundleBase implements
  ProductTypeInterface  {
  /**
   * The Product type ID.
   *
   * @var string
   */
  protected $id;
  /**
   * The Product type label.
   *
   * @var string
   */
  protected $label;
}

Much of this is exactly the same as when we created the importer configuration entity type. The only difference is that we have the bundle_of key in the annotation, which denotes the content entity type this serves is a bundle for. Also, we don’t really need any other fields. Because of that, the ProductTypeInterface can look as simple as this:

namespace DrupalproductsEntity;
use DrupalCoreConfigEntityConfigEntityInterface;
/**
 * Product bundle interface.
 */
interface ProductTypeInterface extends
  ConfigEntityInterface {}

Let’s quickly take a look at the individual handlers, which will seem very familiar by now as well. The list builder looks almost the same as for the Importer:

namespace Drupalproducts;
use DrupalCoreConfigEntityConfigEntityListBuilder;
use DrupalCoreEntityEntityInterface;
/**
 * List builder for ProductType entities.
 */
class ProductTypeListBuilder extends
  ConfigEntityListBuilder {
  /**
   * {@inheritdoc}
   */
  public function buildHeader() {
    $header['label'] = $this->t('Product type');
    $header['id'] = $this->t('Machine name');
    return $header + parent::buildHeader();
  }
  /**
   * {@inheritdoc}
   */
  public function buildRow(EntityInterface $entity) {
    $row['label'] = $entity->label();
    $row['id'] = $entity->id();
    return $row + parent::buildRow($entity);
  }
}

The create/edit form handler also looks very similar, albeit much simpler due to not having many fields on the configuration entity type:

namespace DrupalproductsForm;
use DrupalCoreEntityEntityForm;
use DrupalCoreFormFormStateInterface;
use DrupalCoreMessengerMessengerInterface;
use SymfonyComponentDependencyInjection
  ContainerInterface;
/**
 * Form handler for creating/editing ProductType entities
 */
class ProductTypeForm extends EntityForm {
  /**
   * ProductTypeForm constructor.
   *
   * @param DrupalCoreMessengerMessengerInterface
       $messenger
   */
  public function __construct(MessengerInterface
    $messenger) {
    $this->messenger = $messenger;
  }
  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface
    $container) {
    return new static(
      $container->get('messenger')
    );
  }
  /**
   * {@inheritdoc}
   */
  public function form(array $form, FormStateInterface
    $form_state) {
    $form = parent::form($form, $form_state);
    /** @var DrupalproductsEntityProductTypeInterface
      $product_type */
    $product_type = $this->entity;
    $form['label'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Label'),
      '#maxlength' => 255,
      '#default_value' => $product_type->label(),
      '#description' => $this->t('Label for the Product
        type.'),
      '#required' => TRUE,
    ];
    $form['id'] = [
      '#type' => 'machine_name',
      '#default_value' => $product_type->id(),
      '#machine_name' => [
        'exists' => 'DrupalproductsEntity
          ProductType::load',
      ],
      '#disabled' => !$product_type->isNew(),
    ];
    return $form;
  }
  /**
   * {@inheritdoc}
   */
  public function save(array $form, FormStateInterface
    $form_state) {
    $product_type = $this->entity;
    $status = $product_type->save();
    switch ($status) {
      case SAVED_NEW:
        $this->messenger->addMessage($this->t('Created the
          %label Product type.', [
          '%label' => $product_type->label(),
        ]));
        break;
      default:
        $this->messenger->addMessage($this->t('Saved the
          %label Product type.', [
          '%label' => $product_type->label(),
        ]));
    }
    $form_state->setRedirectUrl($product_type->
      toUrl('collection'));
  }
}

Since we created the form for saving field values, we mustn’t forget about the configuration schema for this entity type:

products.product_type.*:
  type: config_entity
  label: 'Product type config'
  mapping:
    id:
      type: string
      label: 'ID'
    label:
      type: label
      label: 'Label'

Next, we should also quickly write the form handler for deleting product types:

namespace DrupalproductsForm;
use DrupalCoreEntityEntityConfirmFormBase;
use DrupalCoreFormFormStateInterface;
use DrupalCoreMessengerMessengerInterface;
use DrupalCoreUrl;
use SymfonyComponentDependencyInjection
  ContainerInterface;
/**
 * Form handler for deleting ProductType entities.
 */
class ProductTypeDeleteForm extends EntityConfirmFormBase {
  /**
   * ProductTypeDeleteForm constructor.
   *
   * @param DrupalCoreMessengerMessengerInterface
       $messenger
   */
  public function __construct(MessengerInterface
    $messenger) {
    $this->messenger = $messenger;
  }
  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface
    $container) {
    return new static(
      $container->get('messenger')
    );
  }
  /**
   * {@inheritdoc}
   */
  public function getQuestion() {
    return $this->t('Are you sure you want to delete
      %name?', ['%name' => $this->entity->label()]);
  }
  /**
   * {@inheritdoc}
   */
  public function getCancelUrl() {
    return new Url('entity.product_type.collection');
  }
  /**
   * {@inheritdoc}
   */
  public function getConfirmText() {
    return $this->t('Delete');
  }
  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form,
    FormStateInterface $form_state) {
    $this->entity->delete();
    $this->messenger->addMessage($this->t('Deleted @entity
      product type.', ['@entity' => $this->entity->
        label()]));
    $form_state->setRedirectUrl($this->getCancelUrl());
  }
}

You should already be familiar with what we’re doing here as it’s the same as with the Importer entities.

Finally, we should create the menu link to the ProductType entity list URL, just like we did for the other two entity types inside products.links.menu.yml:

entity.product_type.collection:
  title: 'Product types'
  route_name: entity.product_type.collection
  description: 'List Product bundles'
  parent: system.admin_structure
  weight: 99

And the same for the action link used to create a new product bundle, inside products.links.action.yml:

entity.product_type.add_form:
  route_name: 'entity.product_type.add_form'
  title: 'Add Product type'
  appears_on:
    - entity.product_type.collection

Now, we are done. We can clear the caches and run the drush entity-updates development command because Drupal needs to create the type field on the product entities. Once that is done, we can go to the UI at admin/structure/product_type and see our changes.

Note

If when running the entity-updates command you encounter an error that says that the field storage definition for “type” could not be found, it means you’ve likely encountered a bug in Drupal. Either use the patch from the Drupal issue queue (#3126661) or simply uninstall the module and install it back to recreate the product entity type directly with the bundle field installed on it. Hopefully, this is temporary and you won’t encounter this problem.

We now have a Product type entity listing where we can create Product bundles. Moreover, we also have some extra operations since this entity type is used as a bundle: we can manage fields and displays (both for viewing and the forms) for each individual bundle:

Figure 7.4: Managing our product type fields

Figure 7.4: Managing our product type fields

Managing fields and displays would have been possible before creating the bundle had we provided the field_ui_base_route to the Product entity type and created a menu link for it.

Now we can add fields to our individual bundles and can distinguish between our product types—for example, we can have a bundle for goods and one for services. We can well imagine that the two types might require a different set of fields and/or they are being pulled from different external resources. So, let’s just update our importing logic to allow the selection of a bundle because now it is actually mandatory to specify one when attempting to create a product.

We start by adding a new field to the Importer entity type. First, for the interface change:

/**
 * Returns the Product type that needs to be created.
 *
 * @return string
 */
public function getBundle();

Then, we will implement it in the class:

/**
 * The product bundle.
 *
 * @var string
 */
protected $bundle;
...
/**
 * {@inheritdoc}
 */
public function getBundle() {
  return $this->bundle;
}

Next, we must include the new field in the configuration schema:

...
bundle:
  type: string
  label: The product bundle

And add it to the list of fields that get exported with the entities:

*   config_export = {
 *     "id",
...
 *     "bundle"
 *   }

The last thing we will need to do on the Importer entity type is to add the form element for choosing a bundle:

$form['bundle'] = [
  '#type' => 'entity_autocomplete',
  '#target_type' => 'product_type',
  '#title' => $this->t('Product type'),
  '#default_value' => $importer->getBundle() ? $this->
    entityTypeManager->getStorage('product_type')->
      load($importer->getBundle()) : NULL,
  '#description' => $this->t('The type of products that
    need to be created.'),
  '#required' => TRUE,
];

Here, we use an entity_autocomplete form element, which gives us the option to use an autocomplete text field to look up an existing entity and select one of the found ones. The ID of the selected entity will then be submitted in the form as the value. This field definition requires choosing a #target_type, which is the entity type we want to autocomplete. One thing to note is that even if the submitted value is only the ID (in our case, a string), the #default_value requires the full entity object itself (or an array of entity objects). This is because the field shows more information about the referenced entity than just the ID.

In order to load the referenced entity for the default value, we need to inject the EntityTypeManager. You should already know how to do this injection, so I’m not going to show it again here. We simply tack on the dependency to the Messenger service, which is already being injected.

That should be it for the Importer entity type alterations. The one last thing we need to do is handle the bundle inside the JsonImporter plugin we wrote. However, this is as simple as adding the type value when creating the product entity:

if (!$existing) {
  $values = [
    'remote_id' => $data->id,
    'source' => $config->getSource(),
    'type' => $config->getBundle(),
  ];
  /** @var DrupalproductsEntityProductInterface
    $product */
  $product = $this->entityTypeManager->getStorage
    ('product')->create($values);
  ...

And there we have it. Running the import code will now create products of the bundle specified in the Importer configuration.

Our own Drush command

So, our logic is in place, but we will need to create a handy way we can trigger the imports. One option is to create an administration form where we go and press a button. However, a more typical example is a command that can be added to the crontab and that can be run at specific intervals automatically. So, that’s what we are going to do now, and we will do so using Drush.

The Drush command we are going to write will take an optional parameter for the ID of the Importer configuration entity we want to process. This will allow the use of the command for more than just one importer. Alternatively, passing no options will process each importer (in case this is something we want to do later on).

One thing to note is that we won’t focus on performance in this example. This means the command will work just fine for smaller sets of data, but it would be better to use a queue and/or batch processing for larger sets. We will have a chapter dedicated to these subsystems later on, but for now, let’s get on with our example.

Before we actually write our new Drush command, let’s make some alterations to our logic as they will make sense in the context of what we want to do.

First, let’s add a getter method to the Importer plugins to retrieve the corresponding configuration entities. We start with the interface like so:

/**
 * Returns the Importer configuration entity.
 *
 * @return DrupalproductsEntityImporterInterface
 */
public function getConfig();

Then, to the ImporterBase class, we can add the implementation (it will be the same for all individual plugin instances):

/**
 * {@inheritdoc}
 */
public function getConfig() {
  return $this->configuration['config'];
}

As you can see, it’s not rocket science.

Second, let’s add a createInstanceFromAllConfigs() method to the ImporterManager, which will return an array of plugin instances for each existing Importer configuration entity:

public function createInstanceFromAllConfigs() {
  $configs = $this->entityTypeManager->getStorage
    ('importer')->loadMultiple();
  if (!$configs) {
    return [];
  }
  $plugins = [];
  foreach ($configs as $config) {
    $plugin = $this->createInstanceFromConfig($config->
      id());
    if (!$plugin) {
      continue;
    }
    $plugins[] = $plugin;
  }
  return $plugins;
}

Here, we use the loadMultiple() method on the entity storage handler, which, if we use it without any arguments, will load all existing entities. If we get any results, we use our existing createInstanceFromConfig() method to instantiate the plugins based on each configuration entity. That’s it; we can now go ahead and create our Drush command. There are a few steps we need to take.

We need to create a composer.json file for our module. It can look very barebones:

{
  "name": "drupal/products",
  "description": "Importing products like a boss.",
  "type": "drupal-module",
  "autoload": {
    "psr-4": {
      "Drupal\products\": "src/"
    }
  },
  "extra": {
    "drush": {
      "services": {
        "drush.services.yml": "^10"
      }
    }
  }
}

Apart from the normal boilerplate package and autoloader information, we have an extras section where we specify a YAML file where Drush can find the service definition that contains the commands.

Now that we have referenced the Drush-specific services file, let’s go ahead and create it. It looks exactly like the other services files we’re used to:

services:
  products.commands:
    class: DrupalproductsCommandsProductCommands
    arguments: [‚@products.importer_manager']
    tags:
      - { name: drush.command }

As you can see, we have another tagged service (drush.command) whose class should contain some Drush commands. And I already know we will need the plugin manager, so we are already adding it as an argument.

So, let’s see how we can start the command class, which should go in the Commands namespace of our module:

namespace DrupalproductsCommands;
use DrushCommandsDrushCommands;
use SymfonyComponentConsoleInputInputOption;
use DrupalproductsPluginImporterManager;
/**
 * Drush commands for products.
 */
class ProductCommands extends DrushCommands {
  /**
   * @var DrupalproductsPluginImporterManager
   */
  protected $importerManager;
  /**
   * ProductCommands constructor.
   *
   * @param DrupalproductsPluginImporterManager
       $importerManager
   */
  public function __construct(ImporterManager
    $importerManager) {
    $this->importerManager = $importerManager;
  }
  /**
   * Imports the Products
   *
   * @option importer
   *   The importer config ID to use.
   *
   * @command products-import-run
   * @aliases pir
   *
   * @param array $options
   *   The command options.
   */
  public function import($options = ['importer' =>
    InputOption::VALUE_OPTIONAL]) {
    // ... add the logic here.
  }
}

We are extending the DrushCommands base class to inherit all the things necessary or useful for Drush commands. And we have a single method that maps to a single command. What makes this an actual command is an annotation at the top, which describes all the things related to it:

  • @command is the most important and specifies the actual Drush command name.
  • @alias specifies other aliases for the command.
  • @param is simple documentation of what input arguments the command takes. In our case, we don’t have any mandatory arguments. We do have optional arguments though. If we wanted mandatory arguments, we could have simply added more method parameters without defaults.
  • @option specifies the name of the option that can be passed; this is found inside the $options array parameter as one of its keys. And since it’s optional, we use a constant to denote that.

With this definition, we can already use the command. After we clear the cache we can run the command as in the following examples:

drush products-import-run
drush products-import-run —importer=
  my_json_product_importer

Obviously, nothing will happen if we run these because the callback method is empty. So, let’s flesh it out:

$importer = $options['importer'];
if (!is_null($importer)) {
  $plugin = $this->importerManager->
    createInstanceFromConfig($importer);
  if (is_null($plugin)) {
    $this->logger()->error(t('The specified importer does
      not exist.'));
    return;
  }
  $this->runPluginImport($plugin);
  return;
}
$plugins = $this->importerManager->createInstanceFrom
  AllConfigs();
if (!$plugins) {
  $this->logger()->error(t('There are no importers to
    run.'));
  return;
}
foreach ($plugins as $plugin) {
  $this->runPluginImport($plugin);
}

What is happening here? First, we check if an importer ID was passed as an option. If yes, we simply use our importer manager to create an instance of the corresponding plugin and delegate it to a helper method to run the import on that plugin. Otherwise, we use the built-in Drush logger to log an error. On the contrary, if no importer ID has been passed, we use our new createInstanceFromAllConfigs() method on the plugin manager to create plugin instances from all existing configuration entities. We then loop through each and, again, delegate to our helper method to run them.

Before we conclude, let’s see the helper method as well:

protected function runPluginImport(DrupalproductsPlugin
  ImporterPluginInterface $plugin) {
  $result = $plugin->import();
  $message_values = ['@importer' => $plugin->getConfig()
    ->label()];
  if ($result) {
    $this->logger()->notice(t('The "@importer" importer has
      been run.', $message_values));
    return;
  }
  $this->logger()->error(t('There was a problem running the
    "@importer" importer.', $message_values));
}

This method is mostly used for logging the result of the plugin import: a different message depending on the success of the process. And in doing so, we use the actual Importer label rather than the ID that was passed, which makes it nicer to read.

Now if we clear the caches, we can run the command again (with or without an importer ID) and see that it correctly imports the products and prints the message to the terminal. Better yet, we can now add it to our crontab and have it run at specific intervals, once a day, for example.

Summary

In this chapter, we got to implement some fun stuff. We created our very own content and configuration entity types as well as a custom plugin type to handle our logic.

What we built was a Product entity type that holds some product-like data in various types of fields. We even created a bundle configuration entity type so that we can have multiple types of products with the possibility of different fields per bundle—a great data model.

We wanted to be able to import products from all sorts of external resources. For this reason, we created the Importer plugin type, which is responsible for doing the actual imports—a great functional model. However, these plugins only work based on a set of configurations, which we represented via a configuration entity type. These can then be created in the UI and exported into YAML files like any other configuration.

Finally, to use the importers, we created a Drush command, which can process either a single Importer or all the existing ones. This can be used inside a crontab for automatic imports.

There are still some shortcomings in the way we constructed the importing functionality. For example, we added the URL field on the Importer configuration entity as if all imports need to happen from an external resource. What if we want an import to be from a CSV file? The URL field would be superfluous, and we’d need a file upload field on the configuration entity. This very much points to the differences between generic Importer configuration values and plugin-specific ones. In future chapters, we’ll come back to our module and make some adjustments in this respect.

In the next chapter, we will look at the Database API and how we can directly interact with the underlying storage engine.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset