Upgrade Your Drupal Skills

We trained 1,000+ Drupal Developers over the last decade.

See Advanced Courses NAH, I know Enough
Oct 18 2024
Oct 18

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.

Oct 18 2024
Oct 18

Organizations with established workflows and data oversight protocols typically have firm opinions on how those processes should work, look and feel. Replatforming a website or application to Drupal 10 is an exciting opportunity to rethink aspects of your process and business logic, but sometimes you want to just go with what you know.

In a recent project, Bounteous helped a client replatform several sites. In these older platforms, page content was built URL by URL, subjected to a three-step approval process, and promoted to the live site. The client’s expectation was that every page—and more importantly, every asset on that page—would be individually reviewed and approved. 

But how does one moderate embedded assets or shared content in Drupal? Items such as media entities and custom block content can be configured to use a content moderation workflow, but the process is independent of the page nodes on which they’re placed. Client stakeholders—site users responsible for reviewing and approving site content—don’t care about Drupal’s data models or reusable content strategies. Stakeholders want to experience their new or updated content exactly as the end user will.

This is a perfectly reasonable expectation, and it aligns with one of Bounteous’ guiding principles: Quality Assurance testing is a proxy for the customers’ experience: test from the end-user’s perspective. Drupal is a magical Swiss army knife, kept polished and sharp by a robust open source community, so with a bit of clever development and care we can meet this expectation. Improvements in Drupal 10 make this effort even easier going forward.

Basic Workflow Setup

Workflows became part of core in 8.9.x. Within a workflow configuration, one can create moderation states, create transitions between those states, enable Content Moderation Notification and create email rules to be triggered by those transitions.

image

The notification emails may include tokens relative to the content entity being transitioned from moderation state to state. If our entity is intended to be viewed as an independent page, with a URL, then we can include a link to “/node/XXX/latest” and “please review here” in our notification email to our stakeholder.

If the content being moderated is a shared content type, designed to be embedded and viewed in a different page, we would use a different token: we use business logic and entity query results (inspired in part by contrib module Entity Usage) to discover and render links to every URL on which this shared content is found. This shared content may be media entities, custom block content, and even node content in a View.

image

The stakeholder visits and reviews the updated content in situ—the context in which the end user will experience the content. To ensure that end users do not see these updates until approved and published, we use revision-handling techniques in our render cycle that, thanks to some newly merged work in Drupal 10, are now more standardized.

D10 Enhancements

An entity, with apologies to Gertrude Stein, is an entity is an entity, yes?  Entity API allows us to treat custom blocks, media, and taxonomies as if they were nodes – fieldable, translatable, and revisionable.  True, but the methods for handling revisions developed at different rates, and are not uniform from type to type. A patchwork of patches and contrib modules (Media Revisions UI, Block Content Revision UI) had sprung up to handle gaps and inconsistencies.

The road from patch to merge for a standardized revision UI was long and twisty and included assists, encouragement, and reviews from some of Bounteous’ own. The machine name of the revision message text field in media entities is revision_log_message, while it is revision_log for nodes and blocks. But today, developers creating their own specific workflow solutions will have a consistent set of methods they can use to get and compare revision ids. Less time on patches and workarounds means more time focusing on their specific challenges.

image

Media

Suppose we have a media entity with a URL alias of “/download-this-month-data” and a file named “/doc/month_01.xls” in a field. A content editor will create a new draft revision, upload a file named “/doc/month_02.xls” into the field, and save this revision as “Ready for Review.” Our stakeholder is sent a notification and asked to review this change on the homepage where we have a “Call To Action” linking to “/download-this-month-data”.

How can we ensure that, when clicking the link, our stakeholder user downloads “month_02.xls” for review, while our customers, our anonymous users, continue to receive “month_01.xls” until the update is published? 

We created a RouteSubscriber that would send ‘entity.media.canonical’ type requests to a custom controller. Our controller checks for revisions and permissions. 

image

If there is a revision more recent than the default revision and if the user has appropriate permissions, then we load that specific revision. The controller writes a new DocumentResponse directly to the file attached to that revision, including appropriate caching and headers. This nifty work was inspired by Media Alias Display.

Blocks

Suppose a custom block of content has a new draft revision with updated text. For any user visiting a page with that block, the default revision will (by default) be rendered. How do we render that latest revision for an authenticated user?  With an old-school hook_alter().

We’re interested in user-generated content blocks as opposed to non-moderated, system-generated menu- or webform- blocks, so we implement hook_block_view_BLOCK_BASE_ID_alter() with BLOCK_BASE_ID = “custom_block”. Again, we check for revisions and permissions.

image

If we need to override the default block render with the latest revision, we add a pre_render callback to the block render array. The callback function loads the latest block revision, renders the block and overwrites the render array’s content key with the new markup.

Views

Suppose we have a node type that does not have an individual detail view, it is only ever rendered as a listing in a view. When we create a revision for review, we do not want to send the stakeholder to “/node/N/latest”, we want them to review the updates on the view page.

image

We ensure all the appropriate views were built using Content revisions in the Views UI. The sets node_revision as the base table and builds in all the necessary table JOINs. We implement a custom filter plugin that adds a where clause to the query() method, depending on user’s permissions.

image

When configured to return revisions, Views UI only allows you to Show: Fields under Formatting. Fortunately, you do still have the option under Fields to render the entire entity using a view mode. Note that this requires a patch which only a PHP unit test away from the finish line. Finally, we add an unpublished_flag class to the row container field in hook_preprocess_views_view_fields() if the row’s entity is not the default revision. 

image

The fun is multiplied when considering entities which themselves reference other entities. For instance, a block of content is placed on a page in a paragraph entity with a Block Field instance. That block_content entity itself references another page node or media entity. Data from that second node or media entity is what we ultimately render on the page as a tile, but we flag that tile (to authenticated users) as unpublished if either the custom block entity OR the referenced entity are unpublished.

Front End Rendering

When we render these entities with unpublished flags, we add a CSS class that signifies to our stakeholder the block or view listing is showing updated or unpublished information. This can be in the form of a colored border or background or overlay. 

Another class can implement a floating overlay that offers links to directly update the moderation status of the entity, leveraging implementation of contrib module Content Moderation Link. We recommend enhancing the user experience by inserting modal forms into the process, giving the stakeholder the opportunity to add custom messaging in the Revision Log field, and offering a conditional confirmation page, reiterating that publishing a change to this shared entity will affect pages X, Y, and Z.

Bounteous created custom solutions for most of this work, but we note several interesting modules in the community that are following similar paths, such as Moderation Note, and Content Moderation Info Block

There are so many content types with so many points of entry to create solutions. Because of this, the continued standardization of an entity-agnostic set of tools for revision handling is a welcome enhancement. When creating a robust workflow process for your site-builders, remember to treat stakeholder users as the distinct population they are. Address their specific needs and empower them to keep site content correct, timely and safe.

Oct 18 2024
Oct 18

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.

About Drupal Sun

Drupal Sun is an Evolving Web project. It allows you to:

  • Do full-text search on all the articles in Drupal Planet (thanks to Apache Solr)
  • Facet based on tags, author, or feed
  • Flip through articles quickly (with j/k or arrow keys) to find what you're interested in
  • View the entire article text inline, or in the context of the site where it was created

See the blog post at Evolving Web

Evolving Web