How to: Implement an automated Commerce Order state transition
A common requirement for any website that sells products is to have a mechanism in place that ensures orders placed on the website are 'Exportable' - being made available as a file that can be sent across to a different system, to handle the processing of the order.
The Drupal Commerce 2.x module (for Drupal 9, 10) has the concept of order 'Workflows', along with defined 'States' and 'Transitions'. A workflow is a set of states and transitions that the order will go through during its lifecycle. Transitions are applied to an order and this will progress the order from one state to another. By default, a few workflows are provided but for sites that we build, we usually write our own order workflows to handle the specific requirements of the site in question.
A comparison of one of the default Commerce order workflows (on the left) and our own custom workflow (on the right). For more information about creating a custom order workflow, see the excellent documentation on drupalcommerce.org.
One common requirement that doesn’t quite work properly ‘out of the box’ is the ability for an order to be automatically transitioned to a separate 'exported' or 'completed' state, immediately after the order has been paid for successfully. We can achieve the desired functionality with a combination of an event subscriber and a useful entity update hook. Here’s what we did:
Step 1 - Implement the event subscriber.
We’ve already covered event subscribers before in a number of our articles so I won’t go into the specifics here, but the first thing we’ll want to do is create a new one that will be able to react to our Orders’ state transition.
N.B. In this example, we are assuming our custom code will live in a module that already exists, named ‘example_order_exporter’.
First off, we’ll add a new file inside of our custom module to handle our event subscriber. In the example code in this article, we’ll be naming it OrderExporterSubscriber.php and the full path it should live under is example_order_exporter/src/EventSubscriber/OrderExporterSubscriber.php
namespace Drupal\example_order_exporter\EventSubscriber;use Drupal\example_order_exporter\OrderExporter;use Drupal\state_machine\Event\WorkflowTransitionEvent;use Symfony\Component\EventDispatcher\EventSubscriberInterface;class OrderExporterSubscriber implements EventSubscriberInterface { /** * @var \Drupal\example_order_exporter\OrderExporter */ protected $orderExporter; /** * {@inheritDoc} */ public function __construct(OrderExporter $order_exporter) { $this->orderExporter = $order_exporter; } /** * The 'place' transition takes place after the order has been paid for in * full, so we'll subscribe to the post_transition event at this point. */ public static function getSubscribedEvents() { return [ 'commerce_order.place.post_transition' => ['onOrderPlace', -100], ]; } /** * Act on an order after it has been paid for. * * We simply set a success / failure flag for the export here, and then * handle the state change in a hook_commerce_order_update() implementation * as the previous state change to payment will have finished by that point. * * @see example_order_exporter_commerce_order_update() */ public function onOrderPlace(WorkflowTransitionEvent $event) { /** @var \Drupal\commerce_order\Entity\OrderInterface $order */ $order = $event->getEntity(); // Call our service to export the order. $exported = $this->orderExporter->export($order); // Update the success flag on the order of the results of the export. $order->setData('export_success', $exported); }}
Nothing scary here, we are just subscribing to the ‘commerce_order.place.post_transition’ event and defining a function to run in reaction to that event (onOrderPlace). Each commerce order transition defined in your order workflow automatically has a ‘pre_transition’ and ‘post_transition’ event made available, and we are subscribing to the post_transition event for the place transition. In this example, the place transition is the specific transition that happens when the order has been successfully paid for.
Inside our onOrderPlace() function, we get the order entity that is available in this event, calling the custom service that we have injected into the class, to do the exporting of the order. We then set a flag on the order using the setData method (which lets us set arbitrary data against the order) with the result of the order export from our custom service.
Of course, nothing is stopping you from having code inside of this onOrderPlace() function that does the actual exporting but we usually like to separate the logic into its own service class. This separation approach means that we can then easily call the order exporting service in other places that we might want it, such as a Drush command.
We’ll also need to let Drupal know about our new event subscriber so will need to add an entry into our module’s services.yml file, e.g.
services: example_order_exporter.order_export_subscriber: class: Drupal\example_order_exporter\EventSubscriber\OrderExporterSubscriber arguments: ['@example_order_exporter.order_exporter'] tags: - { name: event_subscriber }
In this example, we have also specified our custom order exporter service as an argument to our order subscriber (that would also have a definition in this .yml file). If you have decided to include all of the order export logic directly inside the onOrderPlace() function (or aren’t using a custom service to do such a thing) then you can omit this from the arguments to the subscriber and from the __construct() method of the subscriber in the OrderExporterSubscriber.php file.
Step 2 - The hook_commerce_order_update() implementation
The second part of the work here is to have a hook_commerce_order_update implementation inside of our custom module that will handle checking the order data that we set previously in our subscriber, and then apply the appropriate transition if successful.
It’s important that we do this here in the hook implementation! If we try to apply the transition directly inside of the onOrderPlace function from our event subscriber, then this would start the transitioning process for our new transition before the other transition has fully finished. This means that the original transition wouldn’t have necessarily been completed and any functionality driven from the transition we just applied would run, and then the original state transition that we subscribed to would try and finish off afterwards.
Aside from being logically incorrect, this leads to weird inconsistencies in the order log where you end up with something like this, e.g.
"Order state was moved from Draft to Exported""Order state was moved from Exported to Exported"
Instead of what it should be:
"Order state was moved from Draft to Placed""Order state was moved from Placed to Exported"
Here’s the sample code that would need to go into the .module file of the example_order_exporter module (example_order_exporter.module).
use Drupal\commerce_order\Entity\OrderInterface;/** * Implements hook_commerce_order_update(). */function example_order_exporter_commerce_order_update(OrderInterface $order) { // Check that the current state is 'payment' (i.e. we have finished // transitioning to the payment state) and that the export_success flag is // set to TRUE on the order, which indicates our event subscriber that exports // the order has successfully run. // This hook is called *after* the transition has finished, so we can safely // apply the new transition to 'completed' here. $order_state = $order->getState(); $current_state = $order_state->getId(); if ($current_state === 'payment') { if ($order->getData('export_success') === TRUE) { $order_state_transitions = $order_state->getTransitions(); $transition_to_apply = 'completed'; // Update the state of the order by applying the transition. // If the order state transitions are empty then this order is // 'completed' and shouldn't have its state changed. if (isset($order_state_transitions[$transition_to_apply])) { $order_state->applyTransition($order_state_transitions[$transition_to_apply]); $order->save(); } } }}
We check the current state of the order is what we expect it to be - payment - and if the export_success flag we set previously is true. Only if these two conditions are true do we then try to apply the transition with the useful applyTransition method on the order state.
The $order_state has a useful method called getTransitions which returns an array of all the valid transitions for the order in its current state. This allows us to do a quick check to be sure that the ‘completed’ state (the final state of our order workflow) is present in the list of allowed transitions, before trying to apply it. If not present, this would mean this order has already been exported, and we don't try to export the order again.
We are being slightly sneaky here by calling a save on the order at this point in time, as this commerce_order_update hook is triggered during the postSave hook of the original order save. To be safe, we’ll use the (always handy) hook_module_implements_alter to ensure that our hook implementation here always runs last. This will ensure we aren’t accidentally stopping any other module’s hook_commerce_order_update implementation from running before ours.
/** * Implements hook_module_implements_alter(). */function example_order_exporter_module_implements_alter(&$implementations, $hook) { if ($hook == 'commerce_order_update') { $group = $implementations['example_order_exporter']; unset($implementations['example_order_exporter']); $implementations['example_order_exporter'] = $group; }}
So there you have it, a nice clean way of having an automated transition run on the order without getting into any weird state transition issues caused by calling applyTransition whilst reacting to a previous transition. In this example, it’s used purely when we are exporting an order after payment has been made, but nothing is stopping you from reacting to other order transitions depending on your workflow’s needs!