Customizing Your Drupal Commerce Forms
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.
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
'sorder_item
is not the same as thePurchasedEntity
. Fields associated with an Order Type are assigned at theform_state
level, fields on an Order Item bundle are properties of thePurchasedEntity
. - 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. TheAvailabilityChecker
comes for free, you may add custom ones simply by registering them inyour_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.
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.