Project: Classified Ads Module

A custom classified ads module for Drupal 10+

In: Drupal · Web Development · store.lathes.co.uk


As part of the migration of store.lathes.co.uk from Drupal 7 to Drupal 10, I had to come up with a replacement for the commerce_node_checkout module which I’d used to build the classified adverts pages.

This presented a number of challenges:

Pay to Publish

In the previous iteration of the classified ads system, users would create one of two different types of nodes: ‘For Sale’ or ‘Wanted’. I created these content types through the UI, and everything was done with Rules. If this was to be a module that could be reused in other projects or shared with the community, it would have to provide:

  • A custom entity type for classified ads
  • A product variation type that represents the duration that an advert will be published for when it is bought
  • An order item type with an entity reference field that holds a reference to the classified ad entity to be published when the order is paid
  • A way to populate the order item’s entity reference field with the classified ad when the product is added to the cart
  • An event subscriber that listens for the order paid event; it publishes the advert referenced by the order item and sets the expiry date based on the duration set in the product variation

Custom Entity Class (Classified Ad)

The classified ad entity is a content entity with a few ‘base fields’:

  • State (active, expired, etc)
  • Expiry date
  • Paused date
  • Expiry warning date

There is quite a lot of boilerplate code required to define a custom content entity. The easiest way to to get started is ddev drush generate entity:content.

Having generated a custom entity type one can then add ‘base’ fields. These fields will be common to every bundle, and are not deletable by any role. Because our class extends ContentEntityBase we can include our additional fields in our own version of the baseFieldDefinitions method. It is important to include the various utility fields defined in the parent class, such as id, uuid, langcode, created, changed and default_langcode. This can be done by first calling the parent method and then adding our own fields to the array.

// File: src/Entity/ClassifiedAd.php

public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
   // Call the parent method to create the standard base fields
   $fields = parent::baseFieldDefinitions($entity_type);

   // Add the 'published' field.
   $fields += static::publishedBaseFieldDefinitions($entity_type);

   // Add the 'author' field.
   $fields['user_id'] = BaseFieldDefinition::create('entity_reference')
   ->setLabel(t('Authored by'))
   ->setDescription(t('The user ID of author of the Classified Ad entity.'))
   ->setRevisionable(TRUE)
   ->setSetting('target_type', 'user')
   ->setSetting('handler', 'default')
   ->setTranslatable(TRUE)
   ->setDisplayOptions('view', [
      'label' => 'hidden',
      'type' => 'author',
      'weight' => 0,
   ])
   ->setDisplayOptions('form', [
      'type' => 'entity_reference_autocomplete',
      'region' => 'hidden',
      'settings' => [
         'match_operator' => 'CONTAINS',
         'size' => '60',
         'autocomplete_type' => 'tags',
         'placeholder' => '',
      ],
   ])
   ->setDisplayConfigurable('form', TRUE)
   ->setDisplayConfigurable('view', TRUE);

   /**
    * Add the other basic fields here: status, title, created, changed etc. 
    * These can be copied from any other content entity such as Node or Commerce Product
    */ 

return $fields;
}

It is necessary to write ‘get’ and ‘set’ methods for those fields. I also needed to write some additional methods that are specific to the classified ad entity:

  • getAdProductID() This method returns the product ID of one of the three products that is selected when configuring the advert type. The specific product depends on the current state of the ad. This is used to display the publishing options within the ad info block.
  • getExpiryElapsed() Checks if the expiry date is in the past.
  • extendByInterval($interval) Extends the expiry date by a given period of time.
  • getState() and also methods for each transition: stateActivate(), stateExpire(), statePause(), stateUnpause().
  • pause() and unpause() methods to set the paused date when paused, and the new expiry date when unpaused. Called from within the respective statePause() and stateUnpause() methods.

Entity Form Class

I wanted to be able to hide certain fields from the form based on a permissions check. This is done by overriding the buildForm method in the entity form class. To make the user available to check, we need to extend the ContentEntityForm class, add a property to hold the user account ($account) and override the create method to ‘inject’ it.

// File: src/Form/ClassifiedAdForm.php
class ClassifiedAdForm extends ContentEntityForm {

  /**
   * The current user account.
   *
   * @var \Drupal\Core\Session\AccountProxyInterface
   */
  protected $account;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    // Instantiates this form class.
    $instance = parent::create($container);
    $instance->account = $container->get('current_user');
    return $instance;
  }

This is a common pattern in Drupal 8 onwards, and it is known as ‘dependency injection’. It allows us to pull existing functionality from Drupal core or other modules into our class. This might include the current user, the entity type manager (for loading and saving entities), or any other service that is available in the container. It isn’t something that you need to fully understand to use, but it is a useful pattern to be aware of.

Having made the current user available to the form class, we can now override the buildForm method to group the utility fields together and only display them to users with the manage all ads permission, which is defined in the module’s permissions file.

// File: src/Form/ClassifiedAdForm.php
public function buildForm(array $form, FormStateInterface $form_state) {
    /* @var \Drupal\cl_ads\Entity\ClassifiedAd $entity */
    $form = parent::buildForm($form, $form_state);

    // check the user has the 'manage ads' permission
    $ad_admin_access = FALSE;
    if ($this->account->hasPermission('manage all ads')) {
      $ad_admin_access = TRUE;
    }

    $form['ad_admin'] = [
      '#type' => 'details',
      '#group' => 'advanced',
      '#title' => $this->t('Classified Ad Management'),
      '#open' => FALSE,
      '#weight' => 100,
      '#access' => $ad_admin_access,
    ];

    $form['cl_ads_state']['#group'] = 'ad_admin';
    $form['cl_ads_state']['#weight'] = 0;
    $form['status']['#group'] = 'ad_admin';
    $form['status']['#weight'] = 1;
    $form['cl_ads_expiry_date']['#group'] = 'ad_admin';
    $form['cl_ads_expiry_date']['#weight'] = 2;
    $form['cl_ads_expiry_warning_date']['#group'] = 'ad_admin';
    $form['cl_ads_expiry_warning_date']['#weight'] = 3;
    $form['cl_ads_paused_date']['#group'] = 'ad_admin';
    $form['cl_ads_paused_date']['#weight'] = 4;

    // Check if the ad is new.
    if ($this->entity->isNew()) {
      // Get the ad entity from the form.
      $ad = $this->entity;
      // Get the label of the ad's bundle
      $ad_bundle = $ad->type->entity->label();
      $form['#title'] = $this->t("Post a $ad_bundle advert");
    }
    else {
      $form['#title'] = $this->t('Edit ad #%id', ['%id' => $this->entity->id()]);
    }

    return $form;
  }

Entity Type Definition

It was also necessary to add some configuration options to the entity type class. These options are configurable for each type of classified ad the site builder might create, and appear in the UI when creating a new type. Adding them is somewhat more complicated than adding fields to the entity class because we have to also define the interface and a schema for the configuration.

// File: src/Entity/ClassifiedAdTypeInterface.php
namespace Drupal\cl_ads\Entity;

use Drupal\Core\Config\Entity\ConfigEntityInterface;

/**
* Provides an interface for defining Classified Ad type entities.
*/
interface ClassifiedAdTypeInterface extends ConfigEntityInterface {

   // Add get/set methods for the configuration options here

/**
   * Get's the advert type's term for active ads, such as "for sale" or "wanted"
   *
   * @param string $label
   *   The label for active ads
   *
   * @return $this
   */
  public function getLabel() : ?string;

  /**
   * Get's the advert type's term for complete ads, such as "sold" or "found"
   *
   * @param string $complete_label
   *   The label for complete ads
   *
   * @return $this
   */
  public function getCompleteLabel() : ?string;

   /**
   * Sets the advert type's label for completed ads
   *
   * @param string $complete_label
   *   The label for complete ads
   *
   * @return $this
   */
  public function setCompleteLabel(string $complete_label) : self;

// Several more methods

}

The ClassifiedAdType class has the following protected properties in addition to the standard content entity properties:

  • id The machine name of the advert type.
  • label The label of the advert type, e.g., ‘For Sale’ or ‘Wanted’.
  • complete_label The label of the advert type when it is complete, e.g., ‘Sold’ or ‘Found’.
  • ad_launch_product_id The product ID of the product that is presented to the customer when first publishing the advert.
  • ad_extend_product_id The product ID of the product that is presented to the customer when extending the advert.
  • ad_renew_product_id The product ID of the product that is presented to the customer when renewing the advert.
  • monetized_fields An array of field names that are monetized, i.e., that are only visible when the ad is in its Active state.
  • preview_view_mode The view mode to use when rendering the advert in the listing options page and in the confirmation email send after ordering.

Each of these properties needs get and set methods, which also must be described in the interface as above. In order to save these options the config_export array must be populated (in the ‘annotation’ within the comment) with the names of the properties that should be saved to the configuration file, and a schema file is also required.

// File: src/Entity/ClassifiedAdType.php

namespace Drupal\cl_ads\Entity;

use Drupal\Core\Config\Entity\ConfigEntityBundleBase;
use Drupal\Core\Entity\EntityStorageInterface;

/**
 * Defines the Classified Ad type entity.
 *
 * @ConfigEntityType(
 *   id = "classified_ad_type",
 *   label = @Translation("Classified Ad type"),
 *   handlers = {
 *     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
 *     "list_builder" = "Drupal\cl_ads\ClassifiedAdTypeListBuilder",
 *     "form" = {
 *       "add" = "Drupal\cl_ads\Form\ClassifiedAdTypeForm",
 *       "edit" = "Drupal\cl_ads\Form\ClassifiedAdTypeForm",
 *       "delete" = "Drupal\cl_ads\Form\ClassifiedAdTypeDeleteForm"
 *     },
 *     "route_provider" = {
 *       "html" = "Drupal\cl_ads\ClassifiedAdTypeHtmlRouteProvider",
 *     },
 *   },
 *   config_export = {
 *     "id",
 *     "label",
 *     "ad_launch_product_id",
 *     "ad_extend_product_id",
 *     "ad_renew_product_id",
 *     "complete_label",
 *     "monetized_fields",
 *     "preview_view_mode",
 *   },
 *   config_prefix = "ad_type",
 *   admin_permission = "administer site configuration",
 *   bundle_of = "classified_ad",
 *   entity_keys = {
 *     "id" = "id",
 *     "label" = "label",
 *     "uuid" = "uuid"
 *   },
 *   links = {
 *     "canonical" = "/admin/structure/classified-ads/types/{classified_ad_type}",
 *     "add-form" = "/admin/structure/classified-ads/types/new",
 *     "edit-form" = "/admin/structure/classified-ads/types/{classified_ad_type}/edit",
 *     "delete-form" = "/admin/structure/classified-ads/types/{classified_ad_type}/delete",
 *     "collection" = "/admin/structure/classified-ads/types"
 *   }
 * )
 */
class ClassifiedAdType extends ConfigEntityBundleBase implements ClassifiedAdTypeInterface {

  /**
   * The Classified Ad type ID.
   *
   * @var string
   */
  protected $id;

  /**
   * The Classified Ad type label.
   *
   * @var string
   */
  protected $label;

The schema file is a YAML file that describes the structure of the configuration file. The schema file is named classified_ad_type.schema.yml and is placed in the config/schema directory.

cl_ads.classified_ad_type.*:
  type: config_entity
  label: 'Classified ad type config'
  mapping:
    id:
      type: string
      label: 'ID'
    label:
      type: label
      label: 'Label'
    uuid:
      type: string
    ad_product_details:
      type: mapping
      mapping:
        ad_launch_product_id:
          type: string
          label: 'Classified Ad Launch Product'
        ad_extend_product_id:
          type: string
          label: 'Classified Ad Extension Product'
        ad_renew_product_id:
          type: string
          label: 'Classified Ad Renewal Product'
    monetized_fields:
      type: sequence
      label: 'Monetized Fields'
      sequence:
        type: string     
    complete_label:
      type: string
      label: 'Complete Label'
    preview_view_mode:
      type: string
      label: 'Preview View Mode'

Providing Custom Bundles of Existing Entity Types

As well as the custom entity type for classified ads, I needed to provide a Product Variation that can store a duration and an Order Item that can store a reference to a classified ad. There are two ways for a module to create bundles, both of which take place during the module’s installation process. One way is to create the bundles programmatically:

$bundle = ProductVariationType::create([
  'id' => 'publishing_option',
  'label' => 'Publishing Option',
  'revision' => FALSE,
]);

// Cast a magic spell to add the required fields to the bundle

$bundle->save();

However there is a much easier way. Create the bundle through the UI, then export the configuration either with the command ddev drush config:export or by going to the configuration synchronization page and exporting the configuration from there. Then just copy the relevant files to the config/install directory.

The order item consists of three files:

commerce_order.commerce_order_item_type.cl_ads_orderitem.yml
Defines the order item type
field.field.commerce_order_item.cl_ads_orderitem.field_cl_ads_duration.yml
Defines the field on the order item that stores the duration to be applied to the advert.
field.field.commerce_order_item.cl_ads_orderitem.field_cl_ads_reference.yml
Defines the field on the order item that stores the reference to the classified ad.

In the D7 iteration I used a simple field value, but doing that again wouldn’t offer any control over state transitions or supply any events to subscribe to without more work on my part. On doing some digging I found a few options:

  • Content Moderation: A core module based on the core Workflows module. It allows you to define states and transitions for content entities. This would seem the natural choice, but controlling the content moderation workflow programmatically means it cannot be used to actually moderate the content. Therefore it would be necessary to create a custom workflow plugin to represent the advert states and transitions. This is done using…
  • Workflows (Core): A core module that allows you to define workflows and states for content entities. I did start to build the system using this module, but there were some difficulties.
  • Workflow (Contrib): A contributed module that really ought to have been discontinued when Workflows was added to core. I wasted some time reading the docs for this before realising it was not the core Workflows module.
  • State Machine: This contributed module is bundled with the Commerce module and is used to control the order workflow. Workflows are defined in YAML files and can be used to control the state of any entity. Commerce Order already requires this module, so using it didn’t add any extra dependencies.

I opted for the State Machine module from Commerce, as it will always be present already, offers transitions with access control, and is well documented. States and transitions are both defined in YAML.

I found it necessary to duplicate the ‘activate’ transition so as to allow privileged users to manually activate adverts using the UI, and in order to handle the events dispatched by the two transitions differently.

Changing an advert’s state when it has been paid for

To populate the order item’s reference field with the advert entity when the product is added to the cart, I had to override the cart form. This is done with a hook_form_alter function. With most forms we can now use hook_form_FORM_ID_alter, but Commerce cart forms have a unique ID appended to them which changes every time the page is refreshed or the cart is updated by AJAX. This means we have to use the more general hook_form_alter and check the form ID in the function.

The hook itself just adds a validation function to the form:

// File: cl_ads.module
  if (strpos($form_id, 'commerce_order_item_add_to_cart_form_commerce_product') === 0) {
    $product = $form_state->getFormObject()->getEntity();
    $product_type = $product->bundle();
    if ($product_type == 'cl_ads_orderitem') {
      $form["#validate"][] = "cl_ads_addreference";
    }
  }

And then in the validation we populate the reference field. In fact I went a step further and made it so that any fields that exist on both the product variation and the order item are copied over. This is more robust than simply storing a reference to the purchased entity, which could be deleted or changed. It also paves the way for additional functionality such as a ‘promoted’ field that could be copied over to advert itself.

function cl_ads_addreference($form, FormStateInterface $form_state) {
  $params = \Drupal::routeMatch()->getParameters();
  if ($ad = \Drupal::routeMatch()->getParameter('classified_ad')) {
    $id = $ad->id();
    $values = $form_state->get('values');
    if ( ! $form_state->hasValue('field_cl_ads_reference')){
      $form_state->setValue('field_cl_ads_reference', array (
        0 =>  array (
          'target_id' => $ad->id(), // ID of the ad
        ),
      ));
    }
    $purchased_entity_id = $form_state->getValue('purchased_entity')[0]['variation'];
    $purchased_entity = \Drupal::entityTypeManager()->getStorage('commerce_product_variation')->load($purchased_entity_id);
    $purchased_entity_type = $purchased_entity->bundle();

    // Create a list of all the fields on the product variation
    $variation_fields = $purchased_entity->getFields();
    // Remove any array keys that don't begin with 'field_'
    $variation_fields = array_filter($variation_fields, function($key) {
      return strpos($key, 'field_') === 0;
    }, ARRAY_FILTER_USE_KEY);
    // Create a list of the fields on the order item
    $order_item_fields = $form_state->getFormObject()->getEntity()->getFields();
    // Find the fields that both items have in common
    $common_fields = array_intersect_key($variation_fields, $order_item_fields);
    // Copy the values in common_fields to the form state
    foreach ($common_fields as $field_name => $field) {
      $form_state->setValue($field_name, $purchased_entity->get($field_name)->getValue());
    }
  }
}

Now we have the advert entity reference stored in the order item, we can use the State Machine module to change the advert’s state when the order is paid for. This is done with an event subscriber class that listens for the commerce_order.commerce_order.paid event.

First, the scaffolding. As well as making the advert active we want to create a log entry and send an email, so let’s create a service for logging events related to the advert and inject it into the subscriber class. We also need to inject the email_factory service (from the Symfony Mailer module) so we can send the email. To inject these services we need to first include them in the event subscriber’s service definition.

# File: cl_ads.services.yml
services:
  # Reacts to order paid event
  cl_ads.orderpaidsubscriber:
    class: Drupal\cl_ads\EventSubscriber\OrderPaidSubscriber
    arguments: ['@cl_ads.adlogservice', '@email_factory']
    tags:
      - { name: event_subscriber }

We can then ‘inject’ them into the class via the __construct and create methods. Note that the order of the arguments in the service definition must match the order of the arguments to the __construct function.

// File: src/EventSubscriber/OrderPaidSubscriber.php

namespace Drupal\cl_ads\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Drupal\commerce_order\Event\OrderEvent;
use Drupal\cl_ads\AdLogService;
use Drupal\symfony_mailer\EmailFactory;
use Drupal\cl_ads\Entity\ClassifiedAd;

/**
 * Class OrderPaidSubscriber.
 */
class OrderPaidSubscriber implements EventSubscriberInterface {

  /**
   * The logging service
   *
   * @var \Drupal\cl_ads\AdLogService
   */
  private $adlogservice;

  /**
   * The email factory.
   *
   * @var \Drupal\email_factory\EmailFactory
   */
  protected $emailfactory;  

  /**
   * Constructs a new OrderPaidSubscriber object.
   */
  public function __construct(AdLogService $adlogservice, EmailFactory $emailfactory) {
    $this->adlogservice = $adlogservice;
    $this->emailfactory = $emailfactory;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('adlogservice'),
      $container->get('email_factory')
    );
  }

Then define the event we are subscribing to and the method that will be called when the event is dispatched.

I found that once I had written a ‘transition guard’ to prevent unauthorised users transitioning the advert to the ‘active’ state, using the state transition to set the ad to Active was no longer possible. Instead it is necessary to set the state field value directly.

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    $events['commerce_order.order.paid'] = ['OrderPaid'];
    return $events;
  }

  /**
   * This method is called when the commerce_order.order.paid is dispatched.
   *
   * @param \Symfony\Component\EventDispatcher\Event $event
   *   The dispatched event.
   */
  public function OrderPaid(OrderEvent $event) {
    \Drupal::messenger()->addMessage('OrderPaid event subscriber fired', 'status', TRUE);
    $template_id = 'ad_updated';
    $order = $event->getOrder();
    // Order items in the cart.
    $order_items = $order->getItems();
    foreach ($order_items as $key => $order_item) {
      if ($order_item->bundle() == 'cl_ads_orderitem') {
        // Extend the ad by the value of the duration field
        if ($order_item->hasField('field_cl_ads_duration')){
          $duration = $order_item->get('field_cl_ads_duration')->first();
          $ad = $order_item->get('field_cl_ads_reference')->entity;
          $ad->extendByInterval($duration);
          // Apply the relevant transition
          $state_id = $ad->getState()->getId();
          switch ($state_id) {
            case 'inactive':
              // $success = $ad->stateActivate();
              // $ad->getState()->applyTransitionById('activate_byorder');
              // Set the state value directly instead of calling the transition, bypassing the transition guard
              $ad->cl_ads_state->value = 'active';
              $ad->save();
              $log_template = $email_template = 'ad_activated_by_order';
              $send_email = TRUE;
              break;
            case 'active':
              $ad->save();
              $log_template = $email_template = 'ad_extended_by_order';
              $send_email = TRUE;
              break;
            case 'paused':
              $ad->save();
              $log_template = 'ad_extended_by_order_while_paused';
              $email_template = 'ad_extended_by_order';
              $send_email = TRUE;
              break;
            case 'expired':
              // $ad->stateRenew();
              // $ad->getState()->applyTransitionById('renew_byorder');
              // Set the state value directly instead of calling the transition, bypassing the transition guard
              $ad->cl_ads_state->value = 'active';
              $ad->save();
              $log_template = $email_template = 'ad_renewed_by_order';
              $send_email = TRUE;
              break;
            default:
              break;
          }
        }
        // TODO: Copy any matching fields from the order item to the ad
        // ...

        // Log entry
        $this->adlogservice->log($ad, $log_template, $order_item);
        // Email
        if ($send_email) {
          $email = $this->emailfactory->sendTypedEmail('cl_ads', $email_template, $ad, $order);
          if ($error = $email->getError()) {
            \Drupal::messenger()->addMessage($error, 'error', TRUE);
          }
        }
      }
    }
  }
}

Automatic Expiry and Deletion

I implemented this with a cron job that:

  • Runs an entity query with a condition that the expiry date is in the past
  • Adds the IDs of the entities to a queue
  • Runs a queue worker to process the entities

Cron Jobs

To run any code regularly, we can use hook_cron in our .module file. During the cron run we want to find any adverts that are due for expiry, and add them to a queue. To select the advert entities we can use an entity query.

// File: cl_ads.module

function cl_ads_cron() {

  $expiry_queue = \Drupal::queue('expiry_queue');

  $now = new DrupalDateTime('now');
  $now->setTimezone(new \DateTimezone('UTC'));
  $now_string = $now->format('Y-m-d\TH:i:s');

  $entity_type_manager = \Drupal::service('entity_type.manager');
  $storage = $entity_type_manager->getStorage('classified_ad');
  $query = $storage->getQuery();

  $expiry_ids = $query->condition('cl_ads_expiry_date', $now_string, '<')
    ->condition('cl_ads_state', 'active')
    ->accessCheck(FALSE)
    ->execute();

  foreach ($expiry_ids as $id) {
    $expiry_queue->createItem((object) ['id' => $id]);
  }
}

When writing an entity query it can help to add the conditions one at a time and check the results by adding ->execute() to the query and dpm()ing the results. This is an example of method chaining, where each method returns the object it is called on, allowing another method to be called on it.

Queue Workers

Writing a custom queue plugin allows us to move the bulk of the work out of the cron run; as well as setting the ad’s state to ‘expired’, we will send an email and create a log entry. Cron runs can time out depending on the server configuration, and this may become an issue if there are a large number of ads to expire. Using a queue worker allows the work to be done in batches, and independantly of page requests.

They may be run by cron, or called from Drush. There is also a Queue UI module, or one could write a custom controller to run the queue worker when a route is visited or a UI button is clicked.

When an advert expires an event is dispatched, and a subscriber sends an email to the advert owner. Here’s the full QueueWorker plugin for expiring ads:

// File: src/Plugin/QueueWorker/ExpiryQueueWorker.php

<?php

namespace Drupal\cl_ads\Plugin\QueueWorker;

use Drupal\Core\Queue\QueueWorkerBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\cl_ads\Event\AdExpiredEvent;

/**
 * Processes Tasks for Learning.
 *
 * @QueueWorker(
 *   id = "expiry_queue",
 *   title = @Translation("Expiry Queue Worker"),
 *   cron = {"time" = 60}
 * )
 */
class ExpiryQueueWorker extends QueueWorkerBase implements ContainerFactoryPluginInterface {

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * Constructs a new ExpiryQueueWorker object.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager.
   */
  public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);    
    $this->entityTypeManager = $entity_type_manager;
  }

  /**
   * {@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')
    );
  }

  /**
   * {@inheritdoc}
   */
  public function processItem($data) {
    $storage = $this->entityTypeManager->getStorage('classified_ad');
    $ad = $storage->load($data->id);

    if ($ad && $ad->getExpiryElapsed()) {
      // Clear the expiry_warning_date and paused_date fields if they are set.
      $ad->set('cl_ads_expiry_warning_date', NULL);
      $ad->set('cl_ads_paused_date', NULL);
      // Expire the ad.
      $ad->stateExpire();
      // Dispatch event.
      $event = new AdExpiredEvent($ad);
      $event_dispatcher = \Drupal::service('event_dispatcher');
      $event_dispatcher->dispatch($event, AdExpiredEvent::EXPIRED);
    }
  }
}

Conditionally Hiding Fields

cl_ads_entity_field_access() in cl_ads.module

Creating a Custom Block (Ad Info Block)

  • A block to display advert info (status, expiry date etc) to the owner and site admins.

Creating Tabs with Custom Functionality

  • Define a controller, route and template

Routing and Controllers

  • Primer on routing and controllers

Publishing Options Tab

  • Implement route alteration classes if needed, e.g., AdStateTransitionRouteAlter.
  • Entity query to get log messages

Sending HTML emails

  • Dispatch an event when adverts expire.
  • Subscribe to that event and send an email with symfony_mailer
  • Create templates for the emails
  • Email users to warn them before their ads expire

Module Settings Page

  • Create a module configuration form
  • Define configuration schema in a YAML file

Permissions

Permissions are defines in two ways:

  • A permissions YAML file, e.g., cl_ads.permissions.yml, to define ‘static’ permissions. It must be named modulename.permissions.yml and be placed in the module’s root directory.
  • A permissions callback class, e.g., ClassifiedAdPermissions, to define dynamic permissions (such as those that apply per bundle and therefore are created programmatically). Name the file something sensible like ClassifiedAdPermissions.php and place it in the src directory.

More than one permissions callback class can be defined in a module, but only one permissions YAML file. Permissions callbacks are specified in the YAML file along with the other permissions.

# File: cl_ads.permissions.yml
administer ads:
  title: 'Admin: Configure Classified Ads'
  description: 'Allow to access the administration form to configure Classified Ad entities.'
  restrict access: true

manage all ads:
  title: 'Admin: Manage Classified Ads'
  description: 'Allow to view, edit or delete any property of any Classified Ad'

#   ... other permissions

permission_callbacks:
  - \Drupal\cl_ads\ClassifiedAdPermissions::generatePermissions

Logging and Notifications

  • Define a service to log events.
  • Another controller and template to display ad log messages in a tab
  • Use the symfony_mailer module to send notifications

Delete images when an entity is deleted

  • It’s another event subscriber and a queue worker