Customizing Your Drupal Commerce Forms

August 12, 2021
Senior Developer

Well, that was exciting! Releasing an enterprise-level Drupal Commerce solution into the wild is a great opportunity to take a moment to reflect: How on earth did we pull that off? This client had multiple stores, multiple product offerings, each with its own requirements for shopping, ordering, payment, and fulfillment flow. And some pretty specific ideas about how the User Experience (UX) was to unfold.

Drupal Commerce offers many possible avenues into the world of customization; here are a few we followed.

Can't I Just Config My Way Out of This?

Yes! But no, probably not. Yes, you should absolutely set up a Proof-of-Concept build using just the tools and configurations at your disposal in the admin user interface (UI). How close did you get? Does your implementation need just a couple of custom fields and a theming, or will it need a ground-up approach? This will help you make more informed estimations of the level of effort and number of story points.

Bundle Up

The Drupal Commerce ecosystem, much like Drupal as a whole, is populated by Entities—fieldable and categorizable into types, or bundles. Think about your particular situation and make use of these categorizations if you can.

Separate your physical and digital products, or your hard goods and textiles. Distinct bundles give you independent fieldsets that you can group with view_displays.

Order Types (admin/commerce/config/order-types/default/edit/fields) are the main organizing principle here: if you have a category of unpaid reservations vs. fully paid orders—that sounds like two separate order_types and two separate checkout flows. Softgoods and hardgoods are tracked for fulfillment in two separate third-party systems? Separate bundles. Keep in mind, though, that a Drupal order is an entity and is a single bundle. An order can have multiple order_item types, but only a single order_type.

Order Item Types (admin/commerce/config/order-item-types/default/edit/fields) bridge the gap between products and orders. Order Item bundles include Purchased Entity, Quantity, and Unit Price by default, but different product categories may need different extra fields on the Add to Cart form.

Adding to Cart

Drupal Commerce offers a path to add Add-to-Cart forms to Product views through the Admin UI.

Drupal Commerce path to add Add-to-Cart forms

 

You could alter the form through the field handler, the formatted, or template of course, but we wanted more direct control and flexibility. We created a route with parameters for product and variation IDs—now we could put the form in a modal and reach it from a CTA placed anywhere. The route's controller, given the product variation, other route parameters, and the page context, decided which order_item_type form to present in the modal.

class PurchasableTextileModalForm extends ModalFormBase {
 use AjaxHelperTrait;
 /**
  * {@inheritdoc}
  */
public function buildForm(array $form, FormStateInterface $form_state, Product $product = NULL, ProductVariation $variation = NULL, $order_type = 'textile', $is_edit_form = FALSE) {
  $form = parent::buildForm($form, $form_state, $product, $variation);
  ...

We extended the form from FormBase, incorporated some custom Traits, and used \Drupal\commerce_cart\Form\AddToCartForm as a model. We learned some fun lessons on the way:

  • Don't be shy when loading services—who knows what you'll wind up needing.
  • Keep in mind that the form_state's order_item is not the same as the PurchasedEntity. Fields associated with an Order Type are assigned at the form_state level, fields on an Order Item bundle are properties of the PurchasedEntity.
  • Want to check your cart to see if this particular product variation is already a line-item? \Drupal::service('commerce_cart.order_item_matcher')->match() is your friend.
  • When validating, recall again that PurchasedEntity is an Entity, which means it uses the Entity Validation API. The AvailabilityChecker comes for free, you may add custom ones simply by registering them in your_module.services.yml. Or you may want to create a custom Constraint.

Our add-to-cart modal forms (which we reused on the cart view page for editing existing line-items) turned out to be works of art. We had vanilla javascript calculating totals in real-time, we had a service calculating complex allocation data also in real-time, triggered by ajax. Custom widgets saved values to order_item fields which triggered custom Addon OrderProcessors.

class AddonOrderProcessor implements OrderProcessorInterface {
 /**
  * {@inheritdoc}
  */
 public function process(OrderInterface $order) {
   foreach ($order->getItems() as $order_item) {
...

Recognizing how intricate and interconnected this functionality was going to be, we committed ourselves early on to the necessity of building the forms from scratch.

Wait, What Am I Getting?

The second step of the experience: seeing how full your cart has become after an exuberant shopping session.

Out-of-the-box, Commerce offers a View display at "/cart" of a user's every order item, grouped by order_type.

We wanted separate pages for each order_type, so first we overrode the routing established by commerce_cart and pointed to our own controller which took the order_type as a route parameter.

class RouteSubscriber extends RouteSubscriberBase {
 /**
  * {@inheritdoc}
  */
 protected function alterRoutes(RouteCollection $collection){
   // Override "/cart" routing.
   if ($route = $collection->get('commerce_cart.page')) {
     $route->setDefaults(array(
       '_controller' => ...

That controller passed the order_type as the display_id argument to the commerce_cart_form view, where we had built out multiple displays.

We had a lot of information to show on the cart page that was not available to the View UI. We had the results of our custom allocation service that we wanted to show in a column with other Purchased Entity information. We had add-on fees we wanted to show in the line item's subtotal column. This stuff wasn't registered as fields associated with an entity in Drupal, these were custom calculations.

We registered custom field handlers that we could select in the Views UI, placing them into columns of the table display and styling them with custom field templates. The render function of these field plugins had access to all the values returned in its ResultRow by the view for our custom calculations:

$values->_relationship_entities['commerce_product_variation']->get('product_id')

Let's Transact!

The checkout flow has little customization available off-the-shelf through admin pages. You can reorder the sections on the pages and the Shipping and Tax modules will automatically create panes and sections for you, but otherwise, you get what you get, unless you roll your own.

A custom Checkout Flow starts with a Plugin (so watch your Annotations!) which need not do too much more than define the array of steps. On the other hand, we extended the buildForm() and tucked in a fair amount of alterations, both globally and to specific checkout steps.

Each checkout step can have multiple panes (also plugins: @CommerceCheckoutPane) each with its own form -build, -validate, and -submit functions.

We built custom panes for each step, using shared Traits, extending and reusing existing functionality wherever we could. With a cache clear, our custom panes were available for ordering and placement in the Checkout flow UI.

Manage Form Display tab in Drupal Commerce

 

We managed the order_type-specific fields and collected them in the field_displays tab in the admin UI. We could then easily call for those fields by form_mode in a buildPaneForm() function and render them. We used a similar technique in the validate and submit functions.

$form_display = EntityFormDisplay::collectRenderDisplay($this->order, 'order_reference_detail_checkout');
$form_display->extractFormValues($this->order, $pane_form, $form_state);
$form_display->validateFormValues($this->order, $pane_form, $form_state);

Integration Station

This project had a half-dozen in-coming and out-going integration points with outside systems, including customer info, tax and shipping calculator services, the payment gateway, and an order processing service to which the completed order was finally submitted.

Each integration was a separate and idiosyncratic adventure; it would not be terribly enlightening to relate them here. But we are quite sure that, rather than having custom functionality shoe-horned here and there in a number of hook_alters spread over the whole codebase, keeping our checkout forms tidily in individual files and classes helped the development process immeasurably.

And Finally, Ka-ching

The commerce platform space is a landscape crowded with lumbering giants. It was awfully satisfying to see Team Drupal put together a great-looking, custom solution as robust as the big boys, in likely less time and certainly far more tightly integrated with the content, marketing, and SEO side of things. The depth and flexibility that make Drupal such a powerful platform for content management and presentation can also be used to deeply and efficiently customize all aspects of the shopping and checkout experience with Drupal Commerce.