Set up workflows with State machine on Drupal 8

a typewriter

We saw in a previous post how to set up a publishing process on Drupal 8 with the modules Content moderation and Workflows. We will address here a similar problematic but relying this time on the module State machine, module that will allow us to set up one or more business workflow on any Drupal entity. Note that the state machine module is one of the essential components of Drupal Commerce 2.x.

The operation of the module is quite simple in its design:

  • Workflows must be defined in a custom module using a YAML file: these workflows contain both the different possible states and the different possible transitions between each state.
  • The module provides a new field type: a State field.
  • We can add as many state fields as needed to an entity, and we can configure which workflow each field will use.
  • And now every time a State field is changed, an event will be propagated (see Drupal 8 and events), and we can then act on these events and trigger as many actions as needed

It will thus remain to implement certain logics so that the fields thus created fulfill their role at best.

In particular, we will have to generate the different permissions allowing us to assign rights to each transition (or to adopt a different logic concerning rights management, which could for example be based on certain users's attributes) and still trigger actions appropriate for each active status for a content or any entity.

Note that you may need to apply this patch to have a generic event propagated by the State machine field. If not, you can still simply use propagated standard events, events based on the transition ID triggered.

Let's take a closer look at how state machines work.

Creating workflows

Unlike the Content Moderation and Workflow modules now integrated into the core of Drupal, where we can achieve everything in a few clicks, business workflow configuration with State machine requires the creation of a custom module to declare workflows and a small part of Site Building that we will discuss later briefly.

We will create a new module that we will call my_workflow. This module will have this basic structure.

├── composer.json
├── my_workflow.info.yml
├── my_workflow.module
├── my_workflow.permissions.yml
├── my_workflow.services.yml
├── my_workflow.workflow_groups.yml
├── my_workflow.workflows.yml
├── src
│   ├── EventSubscriber
│   │   └── WorkflowTransitionEventSubscriber.php
│   ├── Guard
│   │   └── PublicationGuard.php
│   ├── MyWorkflowPermissions.php
│   ├── Tests
│   │   └── LoadTest.php
│   ├── WorkflowHelper.php
│   ├── WorkflowHelperInterface.php
│   └── WorkflowUserProvider.php
└── templates
    └── my_workflow.html.twig

 

The most important elements that will interest us here will be the files

  • my_workflow.workflow_groups.yml which will allow us to create workflow groups
  • my_workflow.workflows.yml which will enable us to declare and describe precisely each workflow
  • PublicationGuard.php which will allow us to manage rights access to each transition
  • And lastly WorkflowtransitionEventSubscriber.php which will allow us to react according to the different statutes and transitions of each workflow

A state machine workflow must be part of a workflow group. We will therefore create two workflow groups (publication and technic) for our example by simply declaring them in the my_workflow.workflow_groups.yml file.

publication:
  label: Publication
  entity_type: node
technic:
  label: Technic
  entity_type: node

We can now declare our two business workflows that we will call Publication status and Technical status in my_workflow.workflows.yml file.

publication_default:
  id: publication_default
  label: Default publication
  group: publication
  states:
    new:
      label: New
    draft:
      label: Draft
    proposed:
      label: Proposed
    validated:
      label: Validated
    needs_update:
      label: Needs update
    archived:
      label: Archived
  transitions:
    save_as_draft:
      label: Save as draft
      from: [new, draft, needs_update]
      to: draft
    save_new_draft:
      label: Save new draft
      from: [validated]
      to: draft
    propose:
      label: Propose
      from: [new, draft, needs_update]
      to: proposed
    update_proposed:
      label: Update
      from: [proposed]
      to: proposed
    validate:
      label: Publish
      from: [new, draft, proposed]
      to: validated
    update_validated:
      label: Update
      from: [validated]
      to: validated
    needs_update:
      label: Request changes
      from: [proposed, validated]
      to: needs_update
    unarchive:
      label: Unarchive
      from: [archived]
      to: draft
    archive:
      label: Archive
      from: [validated]
      to: archived

technic_default:
  id: technic_default
  label: Default technic
  group: technic
  states:
    new:
      label: New
    draft:
      label: Draft
    need_review:
      label: Need review
    validated:
      label: Validated
    need_update:
      label: Need update
  transitions:
    save_as_draft:
      label: Save as draft
      from: [new, draft, need_review, validated, need_update]
      to: draft
    ask_review:
      label: Ask review
      from: [new, draft, need_update]
      to: need_review
    update_need_review:
      label: Update review
      from: [need_review]
      to: need_review
    update_need_update:
      label: Stay in need update
      from: [need_update]
      to: need_update
    validate:
      label: Validate
      from: [new, draft, need_review, need_update]
      to: validated
    update_validated:
      label: Update validated
      from: [validated]
      to: validated
    need_update:
      label: Request changes
      from: [need_review, validated]
      to: need_update

For each of the workflows we will declare the different states of the workflow (under the key states) as well as the different possible transitions between these states (under the key transitions). We will then be able to control the various rights to access these transitions very finely.

We have with the file above a standard process, classic, and expected by the State machine module. But as it is a manual configuration (using a YAML file) of a Workflow Plugin State Machine module, nothing forbids us here to add arbitrary properties that will make our business process even smarter. We will only have to detect the presence of these arbitrary properties and their value during the propagation of State machine events. For example, we will associate with certain statuses several properties that may be useful in our wprkflow:

  • published: true to automatically publish content
  • archived: true to unpublish content automatically
  • notify_owner: true to automatically notify the author of the content
  • notify_role: editor to automatically notify users with role editor for example
  • notify_users: field_ref_users to notify referenced users from a particular field (here the field_ref_users field)
  • etc.
  • etc.

We could just add the create_task: my_task property for example to create any task in a third party system, or create a Task entity on the Drupal 8 site. But I think you understand the principle: we can do everything according to the business needs.

For example, our processes will now look more like this, with these new properties that we added for the purposes of the business workflows we want to implement.

publication_default:
  id: publication_default
  label: Default publication
  group: publication
  states:
    new:
      label: New
    draft:
      label: Draft
    proposed:
      label: Proposed
    validated:
      label: Validated
      published: true
    needs_update:
      label: Needs update
    archived:
      label: Archived
      archived: true
  transitions:
    save_as_draft:
      label: Save as draft
      from: [new, draft, needs_update]
      to: draft
    save_new_draft:
      label: Save new draft
      from: [validated]
      to: draft
    propose:
      label: Propose
      from: [new, draft, needs_update]
      to: proposed
    update_proposed:
      label: Update
      from: [proposed]
      to: proposed
    validate:
      label: Publish
      from: [new, draft, proposed]
      to: validated
    update_validated:
      label: Update
      from: [validated]
      to: validated
    needs_update:
      label: Request changes
      from: [proposed, validated]
      to: needs_update
    unarchive:
      label: Unarchive
      from: [archived]
      to: draft
    archive:
      label: Archive
      from: [validated]
      to: archived

technic_default:
  id: technic_default
  label: Default technic
  group: technic
  states:
    new:
      label: New
    draft:
      label: Draft
    need_review:
      label: Need review
      notify_role: technic
      notify_users: field_notify_users
    validated:
      label: Validated
      notify_owner: true
    need_update:
      label: Need update
      notify_owner: true
  transitions:
    save_as_draft:
      label: Save as draft
      from: [new, draft, need_review, validated, need_update]
      to: draft
    ask_review:
      label: Ask review
      from: [new, draft, need_update]
      to: need_review
    update_need_review:
      label: Update review
      from: [need_review]
      to: need_review
    update_need_update:
      label: Stay in need update
      from: [need_update]
      to: need_update
    validate:
      label: Validate
      from: [new, draft, need_review, need_update]
      to: validated
    update_validated:
      label: Update validated
      from: [validated]
      to: validated
    need_update:
      label: Request changes
      from: [need_review, validated]
      to: need_update

 

We can now enable our module, and configure the State machine fields.

Initial configuration

Just add a State field on the type of entity for which we want to set up a workflow. Here we will create a State machine field (Publication status) on the content type Article.

add state machine field

 

And we configure this field to use one of the workflows that we declared in our module.

Configuration state machine

We can configure the form display mode to position the State machine field, and in the example below, we also take this opportunity to disable the native Published field, which will no longer serve us directly.

configuration form mode

We can also configure different display view modes for the State machine field by displaying the value of the current status (here for a Publication status field) or by choosing to display a form that gives access to the different possible transitions (here for another field that we have created also: Technical status) directly from the content's view page.

State machine view mode example

Management of access rights on transitions

By default, State machine does not offer any type of permissions for transitions. By default, all transitions are available to users who can edit a State machine field.

To control access to transitions, just create one (or more) services that will implement GuardInterface. And it is through this service that we can simply prohibit access to transitions, according to our logic. For the example, we will first take a simple case and generate as many permissions as there are possible transitions. The goal is to propose from the permissions management interface checkboxes to assign user roles access to certain transitions.

Let's create the file my_workfloww.permissions.yml, with the following content

permission_callbacks:
  - \Drupal\my_workflow\MyWorkflowPermissions::permissions

Let's create the MyWorkPermissions class

<?php

namespace Drupal\my_workflow;

use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\core\Entity\EntityTypeManagerInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\state_machine\WorkflowManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;


/**
 * Defines a class for dynamic permissions based on workflows.
 */
class MyWorkflowPermissions implements ContainerInjectionInterface {

  use StringTranslationTrait;

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

  /**
   * The workflow manager.
   *
   * @var \Drupal\state_machine\WorkflowManagerInterface
   */
  protected $workflowManager;


  /**
   * Constructor.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager.
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkflowManagerInterface $workflow_manager) {
    $this->entityTypeManager = $entity_type_manager;
    $this->workflowManager = $workflow_manager;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('entity_type.manager'),
      $container->get('plugin.manager.workflow')
    );
  }

  /**
   * Returns an array of transition permissions.
   *
   * @return array
   *   The transition permissions.
   */
  public function permissions() {
    $permissions = [];
    $workflows = $this->workflowManager->getDefinitions();
    /* @var \Drupal\taxonomy\Entity\Vocabulary $vocabulary */
    foreach ($workflows as $workflow_id => $workflow) {
      foreach ($workflow['transitions'] as $transition_id => $transition) {
        $permissions['use ' . $transition_id . ' transition in ' . $workflow_id] = [
          'title' => $this->t('Use the %label transition', [
            '%label' => $transition['label'],
          ]),
          'description' => $this->t('Workflow group %label', [
            '%label' => $workflow['label'],
          ]),
        ];
      }
    }
    return $permissions;
  }

}

Let's clear the caches, and we get our beautiful rights management interface.

Permissions state machine

 

It only remains to create our service that will control access to transitions on the basis of these permissions.

Let's create the file my_workflow.services.yml and declare our service my_workflow.publication_guard

services:
  my_workflow.publication_guard:
    class: Drupal\my_workflow\Guard\PublicationGuard
    arguments: ['@current_user', '@plugin.manager.workflow']
    tags:
      - { name: state_machine.guard, group: publication }
  my_workflow.workflow.helper:
    class: Drupal\my_workflow\WorkflowHelper
    arguments: ['@current_user']
  my_workflow.workflow_transition:
    class: Drupal\my_workflow\EventSubscriber\WorkflowTransitionEventSubscriber
    arguments: ['@my_workflow.workflow.helper']
    tags:
      - { name: event_subscriber }

Our file also contains some other services on which we will have the opportunity to return. Note that your service must be assigned to a workflow group using the group key. And we tag it with the state_machine.guard tag for this service to be collected and evaluated by the state machine module.

Create the PublicationGuard class.

<?php

/**
 * @file
 * Contains \Drupal\my_workflow\Guard\PublicationGuard.
 */

namespace Drupal\my_workflow\Guard;

use Drupal\Core\Session\AccountProxyInterface;
use Drupal\state_machine\Guard\GuardInterface;
use Drupal\state_machine\Plugin\Workflow\WorkflowInterface;
use Drupal\state_machine\Plugin\Workflow\WorkflowTransition;
use Drupal\Core\Entity\EntityInterface;
use Drupal\state_machine\WorkflowManagerInterface;

class PublicationGuard implements GuardInterface {

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

  /**
   * The workflow manager.
   *
   * @var \Drupal\state_machine\WorkflowManagerInterface
   */
  protected $workflowManager;

  /**
   * Constructs a new PublicationGuard object.
   *
   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
   *   The current user..
   */
  public function __construct(AccountProxyInterface $current_user, WorkflowManagerInterface $workflow_manager) {
    $this->currentUser = $current_user;
    $this->workflowManager = $workflow_manager;
  }

  /**
   * {@inheritdoc}
   */
  public function allowed(WorkflowTransition $transition, WorkflowInterface $workflow, EntityInterface $entity) {
    // Don't allow transition for users without permissions.
    $transition_id = $transition->getId();
    $workflow_id = $workflow->getId();
    if (!$this->currentUser->hasPermission('use ' . $transition_id . ' transition in ' . $workflow_id)) {
      return FALSE;
    }
  }

}

The main (and only) method to use is the allowed() method that must return FALSE when you want to deny access to a transition under certain conditions. Otherwise the method should not return anything to allow other services collected by the State Machine GuardFactory to also evaluate their condition. And if none service returns FALSE then the State machine module will give access to the transition.

In the example above, we simply use the permissions previously created to evaluate if the user does NOT have permission and in this case return FALSE.

But we could as well have done without all this logic based on standard Drupal permissions, and evaluate the access to transitions according to much more complex criteria while avoiding a lot of clicks on the management interface of Drupal permissions

For example

/**
 * {@inheritdoc}
 */
public function allowed(WorkflowTransition $transition, WorkflowInterface $workflow, EntityInterface $entity) {
  // Don't allow transition for users without the right attribute.
  if (!$this->userHasComplexAttribute($workflow, $entity)) {
    return FALSE;
  }
}

where the userHasComplexAttribute() method can evaluate what business logic requires (an attribute of the user, the value of any field, or both at the same time, etc.).

In short, possibilities are multiples, within range of a few lines.

Once the implementation of access rights to transitions has been achieved, we will be able to finalize our business workflows by triggering the required actions.

Setting up business logic

Once the fields are created and linked to the workflows, permissions set up, all that remains is to use the Symfony2 event system, available on Drupal 8. Each State machine field will propagate several events during each transition: a pre-transition event and a post-transition event.

The identifiers of these events are of the form [group_id].[transition_id].[pre_transition | post_transition], where group_id is the identifier of the workflow group, transition_id is the identifier of the transition.

With the patch mentioned in the introduction (Fire generic events when transition are applied), a more generic event will be propagated, whose identifier will be state_machine.[pre_transition | post_transition]. It is this last event that we will use in our example.

To react to these events, we will implement an EventSubscriber service, which can be found in the service declaration file of our module below. We will also use another WorkflowHelper service, which will contain some utilitarian methods. 

services:
  my_workflow.publication_guard:
    class: Drupal\my_workflow\Guard\PublicationGuard
    arguments: ['@current_user', '@plugin.manager.workflow']
    tags:
      - { name: state_machine.guard, group: publication }
  my_workflow.workflow.helper:
    class: Drupal\my_workflow\WorkflowHelper
    arguments: ['@current_user']
  my_workflow.workflow_transition:
    class: Drupal\my_workflow\EventSubscriber\WorkflowTransitionEventSubscriber
    arguments: ['@my_workflow.workflow.helper']
    tags:
      - { name: event_subscriber }

 

Let's discover the WorkflowTransitionEventSubscriber class that will allow us to react according to the different transitions.

<?php

namespace Drupal\my_workflow\EventSubscriber;

use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\RevisionableInterface;
use Drupal\my_workflow\WorkflowHelperInterface;
use Drupal\state_machine\Event\WorkflowTransitionEvent;
use Drupal\state_machine\Plugin\Workflow\WorkflowInterface;
use Drupal\state_machine\Plugin\Workflow\WorkflowState;
use Drupal\state_machine_workflow\RevisionManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Event subscriber to handle actions on workflow-enabled entities.
 */
class WorkflowTransitionEventSubscriber implements EventSubscriberInterface {

  /**
   * The workflow helper.
   *
   * @var \Drupal\my_workflow\WorkflowHelperInterface
   */
  protected $workflowHelper;

  /**
   * Constructs a new WorkflowTransitionEventSubscriber object.
   *
   * @param \Drupal\my_workflow\WorkflowHelperInterface $workflowHelper
   *   The workflow helper.
   */
  public function __construct(WorkflowHelperInterface $workflowHelper) {
    $this->workflowHelper = $workflowHelper;
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    return [
      'state_machine.pre_transition' => 'handleAction',
    ];
  }

  /**
   * handle action based on the workflow.
   *
   * @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
   *   The state change event.
   */
  public function handleAction(WorkflowTransitionEvent $event) {
    $entity = $event->getEntity();

    // Verify if the new state is marked as published state.
    $is_published_state = $this->isPublishedState($event->getToState(), $event->getWorkflow());

    if ($entity instanceof EntityPublishedInterface) {
      if ($is_published_state) {
        $entity->setPublished();
      }
      else {
        $entity->setUnpublished();
      }

    }
  }

  /**
   * Checks if a state is set as published in a certain workflow.
   *
   * @param \Drupal\state_machine\Plugin\Workflow\WorkflowState $state
   *   The state to check.
   * @param \Drupal\state_machine\Plugin\Workflow\WorkflowInterface $workflow
   *   The workflow the state belongs to.
   *
   * @return bool
   *   TRUE if the state is set as published in the workflow, FALSE otherwise.
   */
  protected function isPublishedState(WorkflowState $state, WorkflowInterface $workflow) {
    return $this->workflowHelper->isWorkflowStatePublished($state->getId(), $workflow);
  }

}

Here we subscribe to the state_machine.pre_transition event using the getSubscribedEvents() method, and we then call the handleAction() method which in this simple example will allow us to publish or unpublish content based on the status of the State machine field. To do this, we check our custom properties that we associate with the different states of the workflow using the isWorkflowStatePublished() method of the WorkflowHelper Utility class.

This last method will inspect the plugin settings for the current workflow, and check if the current status has the published:true property.

<?php

namespace Drupal\my_workflow;

use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\state_machine\Plugin\Workflow\WorkflowInterface;
use Drupal\state_machine\Plugin\Workflow\WorkflowTransition;

/**
 * Contains helper methods to retrieve workflow related data from entities.
 */
class WorkflowHelper implements WorkflowHelperInterface {

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

  /**
   * Constructs a WorkflowHelper.
   *
   * @param \Drupal\Core\Session\AccountProxyInterface $currentUser
   *   The service that contains the current user.
   */
  public function __construct(AccountProxyInterface $currentUser) {
    $this->currentUser = $currentUser;
  }


  /**
   * {@inheritdoc}
   */
  public function isWorkflowStatePublished($state_id, WorkflowInterface $workflow) {
    // We rely on being able to inspect the plugin definition. Throw an error if
    // this is not the case.
    if (!$workflow instanceof PluginInspectionInterface) {
      $label = $workflow->getLabel();
      throw new \InvalidArgumentException("The '$label' workflow is not plugin based.");
    }

    // Retrieve the raw plugin definition, as all additional plugin settings
    // are stored there.
    $raw_workflow_definition = $workflow->getPluginDefinition();
    return !empty($raw_workflow_definition['states'][$state_id]['published']);
  }

  /**
   * {@inheritdoc}
   */
  public function isWorkflowStateArchived($state_id, WorkflowInterface $workflow) {
    // We rely on being able to inspect the plugin definition. Throw an error if
    // this is not the case.
    if (!$workflow instanceof PluginInspectionInterface) {
      $label = $workflow->getLabel();
      throw new \InvalidArgumentException("The '$label' workflow is not plugin based.");
    }

    // Retrieve the raw plugin definition, as all additional plugin settings
    // are stored there.
    $raw_workflow_definition = $workflow->getPluginDefinition();
    return !empty($raw_workflow_definition['states'][$state_id]['archived']);
  }

}

We could just as easily check for the presence of notify_owner, notify_role, notify_users, and so on.

For example, should we notify some referenced users from a certain field? We will then recover the identifier of the field referencing them:

/**
 * {@inheritdoc}
 */
public function isWorkflowStateNotifyUsers($state_id, WorkflowInterface $workflow) {
  // We rely on being able to inspect the plugin definition. Throw an error if
  // this is not the case.
  if (!$workflow instanceof PluginInspectionInterface) {
    $label = $workflow->getLabel();
    throw new \InvalidArgumentException("The '$label' workflow is not plugin based.");
  }

  // Retrieve the raw plugin definition, as all additional plugin settings
  // are stored there.
  $raw_workflow_definition = $workflow->getPluginDefinition();
  $field_name = !empty($raw_workflow_definition['states'][$state_id]['notify_users']) ? $raw_workflow_definition['states'][$state_id]['notify_users'] : FALSE;
  return $field_name;
}

Then we can notify them from the EventSubscriber service.

/**
 * handle action based on the workflow.
 *
 * @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
 *   The state change event.
 */
public function handleAction(WorkflowTransitionEvent $event) {
  $entity = $event->getEntity();

  // Verify if we should notify some users.
  $field_name = $this->workflowHelper-> isWorkflowStateNotifyUsers($event->getToState(), $event->getWorkflow());

  if ($entity instanceof FieldableEntityInterface) {
    if ($field_name) {
      $this->notifyUsers($field_name, $event, $entity);
    }

  }
}

In a few lines and some methods, we can now implement any necessary reaction based on a transition and a particular status, let's remember, on any entity of Drupal 8.

Content Moderation or State Machine?

Which solution to choose between Content moderation and State machine? We were able to see it during this long presentation of the State machine module, this solution is primarily oriented Drupal 8 Developer, in contrast to the Content moderation solution, more oriented Drupal site builder, which allows to set up a workflow a few clicks (even though this solution can be extended by additional developments as well).

Each solution corresponds to a particular need: Content moderation (and the workflows module) responds very quickly to the need for one (and only one) standard publication workflow on contents (node ​​entities) of a drupal 8 site, while State machine can respond, just as quickly, to more complex needs (multiple workflows on the same content, workflows on any drupal 8 entity).

For their support and predictable maintenance, Content moderation as a Drupal 8 core module has an undeniable advantage. But the State machine module is not left out because it is one of the major building blocks of Drupal Commerce 2.x. These two solutions are therefore guaranteed to be in the landscape of the Drupal ecosystem for a certain time. Be that as it may, this aspect should not be a major element in a decision between a particular solution. Do not hesitate to contact a Drupal 8 expert who can advise you on the best strategy to adopt in relation to your project.

If you wish, you can recover the source code of the examples presented in this post on this GitHub repository.

 

Commentaires

Soumis par fabrice le 04/12/2017 à 12:08 - Permalien

There is not a better solution. It depends on your needs. You can even mix the two solutions : Content moderation for a classic editoral workflow if you need to manage current/default revisions and add another workflow with State machine.

Ajouter un commentaire