Creating a custom checkout pane for Drupal Commerce

Profile picture for user Alex Liannoy

Drupal commerce (especially, in the D8/D9 branch) is an incredible piece of software in my opinion. It has a good architecture, lot’s of out-of-box features, and pretty neat documentation.
Though, it happens quite often that the standard Commerce checkout workflow does not really fit the business needs.

It is time when you start altering all possible forms and endup with an unsupportable mess. To avoid that, Commerce Guys (sorry, I meant Centarro) introduced a nice API to create your own checkout panes with as sophisticated logic as you need.

In this article, I will describe how to create a few kinds of checkout panes, starting with a basic one and finishing with embedding a complete cart view with all required fields. BTW, embedding a cart section might be helpful for guys who want to build a one-page checkout process.

1. Creating a basic commerce checkout pane

Fortunately, defining a basic commerce checkout pane is not a rocket science and is just as straightforward as any other plugin in Drupal 8/9.

<?php
namespace Drupal\mymodule\Plugin\Commerce\CheckoutPane;

use Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneBase;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\commerce\InlineFormManager;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface;

/**
 * Allows customers to add comments to the order.
 *
 * @CommerceCheckoutPane(
 *   id = "mymodule_custom_pane",
 *   label = @Translation("Custom Pane"),
 *   default_step = "order_information",
 *   wrapper_element = "fieldset",
 * )
 */
class CustomPane extends CheckoutPaneBase implements CheckoutPaneInterface {

In this class, you need to define at least one method – buildPaneForm(), which is responsible for returning a form renderable array. Obviously, you can output any other renderable array or even markup with plain HTML, if you need.

/**
   * {@inheritdoc}
   */
public function buildPaneForm(array $pane_form, FormStateInterface $form_state, array &$complete_form) {
    $pane_form[‘nice_markup’] = [
    ‘#type’ => ‘markup’,
    ‘#markup’ => ‘’,
];

    $pane_form[‘gift_confirmation'] = [
      '#type' => checkbox,
      '#title' => $this->t('This is a gift. Do not include receipt.'),
      '#default_value' => $this->order->get(‘field_gift_confirmation')->getValue(),
      '#required' => FALSE,
    ];

    return $pane_form;
  }

Since we’re defining a checkout pane form, we need to create a submit callback and handle the retrieved data from the form.

/**
   * {@inheritdoc}
   */
  public function submitPaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
    $value = $form_state->getValue($pane_form['#parents']);
    $this->order->set(‘field_gift_confirmation', $value[‘gift_confirmation']);
  }

We don’t have much of a hassle in our code, so don’t need to validate user input. Though, if your code is a bit more complex, I suggest you adding a validation callback as well.

public function validatePaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
// Do your magic here.
}

2. Using inline form for a complex workflow

If you have multiple fields, custom AJAX callbacks and have some heavylifting which can be compared with shipping methods or profiles handling, basic checkoutPane form can be a bottleneck for you.
Those guys, who tried customizing checkout form heavily on Drupal 7, can understand what I mean. Nested forms can hurt your mental health very easily.

That is why the commerce team decided to introduce an InlineForm API. It is meant to make these nested subforms more manageable and avoid conflicts between them. You can review the original Drupal.org issue here: https://www.drupal.org/project/commerce/issues/3003121

I will use a real-life example for this. For one of our projects, we needed to add a cart table to the checkout page.

First, you need to define your inline form Plugin. The API is pretty straightforward and very similar to the checkout pane itself. Below is an example of a simple form which embeds a cart view.

<?php

namespace Drupal\sharethelove_global\Plugin\Commerce\InlineForm;

use Drupal\commerce\Plugin\Commerce\InlineForm\InlineFormBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\views\Views;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\RedirectCommand;

/**
 * Provides an inline form for the commerce cart view.
 *
 * @CommerceInlineForm(
 *   id = "cart_view",
 *   label = @Translation("Cart form"),
 * )
 */
class Cart extends InlineFormBase {

  /**
   * The view object.
   *
   * @var \Drupal\views\Views
   */
  protected $view;

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

  /**
   * Constructs a new CouponRedemption object.
   *
   * @param array $configuration
   *   A configuration array containing information about the plugin instance.
   * @param string $plugin_id
   *   The plugin_id for the plugin instance.
   * @param mixed $plugin_definition
   *   The plugin implementation definition.
   * @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 defaultConfiguration() {
    return [
      // The order_id is passed via configuration to avoid serializing the
      // order, which is loaded from scratch in the submit handler to minimize
      // chances of a conflicting save.
      'order_id' => '',
    ];
  }

  /**
   * {@inheritdoc}
   */
  protected function requiredConfiguration() {
    return ['order_id'];
  }

  /**
   * {@inheritdoc}
   */
  public function buildInlineForm(array $inline_form, FormStateInterface $form_state) {
    $inline_form = parent::buildInlineForm($inline_form, $form_state);

    $order = $this->entityTypeManager->getStorage('commerce_order')->load($this->configuration['order_id']);
    if (!$order) {
      throw new \RuntimeException('Invalid order_id given to the coupon_redemption inline form.');
    }

$inline_form['#configuration'] = $this->getConfiguration();
    $inline_form['cart'] = [
      '#prefix' => '<div class="cart cart-form">',
      '#suffix' => '</div>',
      '#type' => 'view',
      '#name' => 'commerce_cart_form',
      '#arguments' => [$order->id()],
      '#embed' => TRUE,
      '#attached' => [
        'library' => ['sharethelove_global/cart_features'],
      ],
    ];

    return $inline_form;
  }
}

Once the InlineForm is defined, it is time to create your checkout pane plugin and reuse your inline form right in it.
Now, your buildPaneForm method needs to be similar to this:

/**
   * {@inheritdoc}
   */
  public function buildPaneForm(array $pane_form, FormStateInterface $form_state, array &$complete_form) {
    $inline_form = $this->inlineFormManager->createInstance('cart_view', [
      'order_id' => $this->order->id(),
    ]);

    $pane_form['form'] = [
      '#parents' => array_merge($pane_form['#parents'], ['form']),
    ];
    $pane_form['form'] = $inline_form->buildInlineForm($pane_form['form'], $form_state);

    return $pane_form;
  }

I am sure you’ve noticed $this->inlineFormManager property. This is a ‘plugin.manager.commerce_inline_form’ service and needs to be referenced using dependency injection, as usual.
Also, I suggest checking out CouponRedepmtion form plugin and a checkout pane from commerce_promotion module. It’s a good reference and can give you a good understanding of how all of this should work.

3. Embedding a cart view on a checkout page

Even though a previous section looks like working cart views, in truth it is not. The form markup was embedded as expected, but unfortunately, the main important logic is not working. You cannot change the quantity, remove line item, etc. It’s still just a content view.

For those who are searching for a solution, I will describe how we’ve managed to make it work.
First of all, I’ve added a new button, which does the refreshing of the cart. Now my buildInlineForm method looks the following way:

/**
   * {@inheritdoc}
   */
  public function buildInlineForm(array $inline_form, FormStateInterface $form_state) {
    $inline_form = parent::buildInlineForm($inline_form, $form_state);

    $order = $this->entityTypeManager->getStorage('commerce_order')->load($this->configuration['order_id']);
    if (!$order) {
      throw new \RuntimeException('Invalid order_id given to the coupon_redemption inline form.');
    }

    $inline_form['#configuration'] = $this->getConfiguration();
    $inline_form['cart'] = [
      '#prefix' => '<div class="cart cart-form">',
      '#suffix' => '</div>',
      '#type' => 'view',
      '#name' => 'commerce_cart_form',
      '#arguments' => [$order->id()],
      '#embed' => TRUE,
      '#attached' => [
        'library' => ['sharethelove_global/cart_features'],
      ],
    ];

    $inline_form['update'] = [
      '#type' => 'submit',
      '#value' => t('Update Cart'),
      '#name' => 'update_cart',
      '#attributes' => [
        'class' => ['stl-cart-refresh'],
      ],
      '#limit_validation_errors' => [
        $inline_form['#parents'],
      ],
      '#submit' => [
        [get_called_class(), 'refreshCart'],
      ],
      '#ajax' => [
        'callback' => [get_called_class(), 'ajaxRefreshForm'],
        'element' => $inline_form['#parents'],
      ],
    ];

    return $inline_form;
}

Note: #submit and #ajax properties on this button. Keep in mind that ‘ajaxRefreshForm' is introduced in Drupal\commerce\AjaxFormTrait and can be very useful when you are trying to build a one-page checkout. This ‘ajaxRefreshForm’ method refreshes the whole checkout form without any additional movements.

In this case, the only thing we needed to handle is a ‘refreshCart’ method itself. Most of it I’ve ripped off the commerce_cart module, though with some limitations. For example, for some reason, I could not access the $form_state value on this level and without a choice, needed to fetch data from the user input.

Also, to make my life easier, I avoided adding a canonical “remove” button and just used zero quantity to remove the order item.

In the end, my ‘refreshCart’ method, looked like this:

/**
   * Submit callback for the "Update Cart" button.
   */
  public static function refreshCart(array &$inline_form, FormStateInterface $form_state) {
    $input = $form_state->getUserInput();
    
    if (isset($input['edit_quantity'])) {
      $cart = \Drupal::entityTypeManager()->getStorage('commerce_order')->load($inline_form['stl_cart_pane']['form']['#configuration']['order_id']);
      $cart_manager = \Drupal::service('commerce_cart.cart_manager');
      $view = Views::getView('commerce_cart_form');
      $view->setDisplay('default');
      // contextual relationship filter  
      $view->setArguments([$cart->id()]);
      $view->execute();
      
      /** @var \Drupal\commerce_order\Entity\OrderInterface $cart */
      $save_cart = FALSE;

      foreach ($input['edit_quantity'] as $row_index => $quantity) {
        if (!is_numeric($quantity) || $quantity < 0) {
          // The input might be invalid if the #required or #min attributes
          // were removed by an alter hook.
          continue;
        }
        /** @var \Drupal\commerce_order\Entity\OrderItemInterface $order_item */
        $row = $view->result[$row_index];
        $order_item = $row->_relationship_entities['order_items'];

        if ($order_item->getQuantity() == $quantity) {
          // The quantity hasn't changed.
          continue;
        }

        if ($quantity > 0) {
          $order_item->setQuantity($quantity);
          $cart_manager->updateOrderItem($cart, $order_item, FALSE);
        }
        else {
          // Treat quantity "0" as a request for deletion.
          $cart_manager->removeOrderItem($cart, $order_item, FALSE);
        }
        $save_cart = TRUE;
      }

      if ($save_cart) {
        $cart->save();
        $form_state->setRebuild();
      }
  }

A few hints for those, who are going to follow this path and embed the Cart form on a page:

  1. You might want to remove default actions from the cart view. Usually, these are “Update cart” and “Checkout” buttons. You can do this using a regular form alter.
  2. If you want to have a cart updated automatically, without need of “Update cart” button, you can simply add a short JS script which will trigger “Update cart” on “Quantity” input change. Keep in mind that you need to use setTimeout carefully to avoid race condition when the user changes quantity multiple times when the AJAX request is still in progress.
  3. BTW, I have a piece of code for the item #2. Feel free to shoot me a message if you think it needs to be included in the article. Honestly speaking, I excluded it to not make the blog post bloated.

Thank you for reading this article to the end. Hopefully, it has helped you to save some time :)
If you think that creating a video content for such kind of articles is a decent idea, feel free to shoot me a message via the contact us form. I will be glad to receive some feedback from you.