DEV Community

Rosen Hristov
Rosen Hristov

Posted on • Edited on

Building a Chat Assistant Module for Drupal Commerce

Most e-commerce integrations follow the same playbook: install a plugin, give it API credentials, let it pull data from your store on a schedule. This works on Shopify. It works on WooCommerce (mostly). On Drupal Commerce, it falls apart.

Drupal Commerce stores are not uniform. Two stores might both sell shoes, but one uses product variations with attribute fields, the other uses referenced paragraph entities with field collections. One has a custom "brand" taxonomy, the other stores brand as a plain text field on the product. Content types, field configurations, display modes: everything is configurable.

I spent 12 years building Drupal sites before I started working on Emporiqa, a chat assistant for e-commerce stores. When it came time to build the Drupal integration, I had to make a choice: try to query every possible Drupal schema from the outside, or let Drupal tell me what the data looks like.

I went with webhooks. The module pushes data out. The external service never queries Drupal directly.

Why Webhooks Instead of an API Client

The pull-based approach means writing an API client that understands your store's schema. What fields exist on commerce_product? Which field holds the brand? Where are the images? Is that a media reference or a direct file field? Are variations inline or referenced?

You end up building a Drupal-specific API consumer that needs to handle every field type, every entity reference, every display configuration. And it breaks whenever someone adds a custom field or changes a view mode.

The webhook approach inverts this. The Drupal module already has full access to the entity system. It knows the field types, the references, the translations. It builds the payload on the Drupal side, where all that context is available, and sends a flat JSON payload to the webhook endpoint.

The receiving side gets a standardized structure regardless of how the Drupal store is configured internally:

{
  "type": "product.created",
  "data": {
    "identification_number": "42",
    "sku": "HB-GTX-42",
    "channels": [""],
    "names": {"": {"en": "Hiking Boot GTX", "de": "Wanderstiefel GTX"}},
    "descriptions": {"": {"en": "Waterproof hiking boot with...", "de": "Wasserdichter Wanderstiefel mit..."}},
    "links": {"": {"en": "https://store.com/hiking-boot-gtx", "de": "https://store.com/de/wanderstiefel-gtx"}},
    "categories": {"": ["Footwear"]},
    "brands": {"": "Salomon"},
    "prices": {"": [
      {"currency": "USD", "current_price": 159.99, "regular_price": 189.99}
    ]},
    "availability_statuses": {"": "available"},
    "attributes": {"": {"en": {"material": "Gore-Tex", "weight": "450g"}, "de": {"Material": "Gore-Tex", "Gewicht": "450g"}}},
    "images": {"": ["https://store.com/boot-1.jpg"]}
  }
}
Enter fullscreen mode Exit fullscreen mode

The module handles extracting categories from taxonomy references, brand from whatever field type the store uses, images from media entities or file fields. All translations are bundled into a single webhook event — one event per product, with nested language keys.

Entity Hooks, Not cron

Products sync when they change, not on a schedule. The module hooks into Drupal's entity CRUD events using the OOP hook pattern (Drupal 10.3+):

// In EmporiqaHooks:
public function commerceProductInsert(ProductInterface $product): void {
    $this->queueProductEvent($product, 'created');
}

public function commerceProductUpdate(ProductInterface $product): void {
    $this->queueProductEvent($product, 'updated');
}

public function commerceProductDelete(ProductInterface $product): void {
    $this->queueProductEvent($product, 'deleted');
}
// ... same for variations and nodes
Enter fullscreen mode Exit fullscreen mode

When a store admin saves a product, the hook fires, builds the webhook payload, and drops it into Drupal's queue system. A queue worker picks it up and sends the HTTP request. If the request fails (network issue, timeout), the queue retries.

This is standard Drupal: entity hooks, QueueInterface, QueueWorkerBase. Nothing unusual. If you have built a custom sync module before, this pattern is familiar.

The queue part matters. Sending webhooks synchronously during entity save would slow down the admin UI. Product saves should feel instant. The webhook can happen a few seconds later, in the background.

Display Modes for Field Mapping

Here is the part that took the most iteration. The module needs to know which fields to include in the webhook payload. Hardcoding field names ("field_brand", "field_image") would break on every store that uses different names.

The module auto-detects available fields during installation: taxonomy references for category and brand, image fields, description fields. It stores the mapping in configuration. The settings form at /admin/config/emporiqa lets you adjust which fields map to what.

For products, this config-based field mapping is the primary approach. The store developer picks "field_brand" from a dropdown, not by writing PHP. Automatic detection on install means most stores get reasonable defaults without touching the settings form.

For pages and advanced use cases, the module also supports a custom emporiqa view mode. If configured, the module renders the entity using that display mode and uses the output. This is useful when you need computed fields or complex field formatters to produce the right text.

Alter Hooks: Four Extension Points

Drupal developers expect hooks. The module provides four:

hook_emporiqa_entity_sync_alter controls whether an entity syncs at all:

function mymodule_emporiqa_entity_sync_alter(bool &$sync, EntityInterface $entity, string $entity_type, string $operation) {
  // Skip draft products
  if ($entity->get('moderation_state')->value !== 'published') {
    $sync = FALSE;
  }

  // Skip products in a specific category
  $category = $entity->get('field_category')->entity;
  if ($category && $category->label() === 'Internal Only') {
    $sync = FALSE;
  }
}
Enter fullscreen mode Exit fullscreen mode

hook_emporiqa_data_alter modifies the payload before it is sent:

function mymodule_emporiqa_data_alter(array &$data, array &$context) {
  $entity = $context['entity'];

  // Add vehicle compatibility from a custom field
  if ($entity->hasField('field_vehicle_compatibility')) {
    $vehicles = [];
    foreach ($entity->get('field_vehicle_compatibility') as $item) {
      $vehicles[] = $item->entity->label();
    }
    $data['data']['attributes']['compatible_vehicles'] =
      implode(', ', $vehicles);
  }
}
Enter fullscreen mode Exit fullscreen mode

hook_emporiqa_cart_alter intercepts cart operations before they execute:

function mymodule_emporiqa_cart_alter(array &$result, string $action, array $data) {
  // Enforce maximum quantity per item
  if ($action === 'add' && $data['quantity'] > 10) {
    $result = ['error' => 'Maximum 10 items per product.'];
  }
}
Enter fullscreen mode Exit fullscreen mode

hook_emporiqa_order_tracking_alter provides custom order lookup for non-Commerce order systems:

function mymodule_emporiqa_order_tracking_alter(?array &$response, string $order_identifier, array $body) {
  // Look up from external ERP
  $order = my_erp_get_order($order_identifier);
  if ($order) {
    $response = [
      'order_id' => $order->id,
      'status' => $order->status,
      'placed_at' => $order->created_at,
      'items' => $order->items,
    ];
  }
}
Enter fullscreen mode Exit fullscreen mode

All four hooks live in your custom module, not in the Emporiqa module. When the module updates via Composer, your customizations stay untouched.

I used the data_alter pattern on an auto parts store. Products had vehicle compatibility stored as entity references to a "Vehicle" content type with year/make/model fields. The hook flattened that into a comma-separated string in the attributes field, so the chat assistant could answer "Does this fit a 2019 Ford Focus?" without needing to understand Drupal's entity reference system.

Full Sync and Reconciliation

Real-time entity hooks handle most cases. But what about products that existed before the module was installed? Or data that got out of sync after a server migration?

Drush commands handle bulk sync:

drush emporiqa:sync-products    # Sync all products (alias: em:sp)
drush emporiqa:sync-pages       # Sync pages (alias: em:spg)
drush emporiqa:sync-all         # Both at once (alias: em:sa)
drush emporiqa:test-connection  # Verify webhook connectivity (alias: em:tc)
Enter fullscreen mode Exit fullscreen mode

You can also trigger a full sync from the admin UI — the Sync tab at /admin/config/services/emporiqa runs the sync with a Batch API progress bar. No command line needed.

The sync uses a session-based reconciliation protocol: the Drush command sends sync.start, then every product with a session ID attached, then sync.complete. The receiving side tracks which products it saw during the session and soft-deletes anything missing. This handles "I deleted 50 products from Drupal while the module was disabled" cleanly.

The --batch-size flag controls memory usage for large catalogs. For stores with 20,000+ products, dropping it to 25 keeps things under control.

I wrote about the receiving side of this pipeline — batch embeddings, hash-based skip logic, and the reconciliation race condition that deleted half a catalog — in Syncing 60,000 Products Without Breaking Everything.

Cart Operations and Conversion Tracking

Two features that go beyond sync:

In-chat cart operations. The module provides cart API endpoints that work with commerce_cart directly. Customers can add products to cart, update quantities, and proceed to checkout from the chat conversation. The cart_alter hook lets you intercept operations (enforce limits, validate items) without touching the module.

Conversion tracking. An event subscriber listens for Commerce order completion and sends an order.completed webhook. This links the chat session to the purchase, so you can see chat-attributed revenue on the dashboard.

Order Tracking

The module provides a built-in endpoint at /emporiqa/api/order/tracking. If Commerce Order is installed, it works out of the box: looks up orders by order number, returns status, items, and totals. Email verification is enabled by default — the customer must provide the email used on the order before any data is returned.

For non-Commerce order systems (external ERP, custom tables), implement hook_emporiqa_order_tracking_alter() to provide your own lookup logic.

Order tracking is optional. If you don't configure it, customers asking about orders get routed to the customer support agent instead.

Multilingual

The module works with Drupal's translation system. Products sync with all translations in a single webhook event — if you have English, German, and French translations, they are bundled as nested language keys in one payload. The chat widget detects the customer's language from the embed tag and returns responses in that language.

What This Doesn't Cover

  • Variation complexity. Drupal Commerce variations (sizes, colors) are their own entities. The module syncs parent products and variations separately. The chat assistant stitches them back together at query time. This works for standard variation structures but may need the data_alter hook for highly customized setups.
  • Custom pricing. The webhook payload sends prices as a channel-keyed object (e.g., {"": [...]}) where each channel contains an array of price entries per currency (each has current_price, optional regular_price, tax fields, and volume tier prices). This covers multi-currency, multi-channel, and B2B scenarios, but complex discount rules (dynamic coupon codes, customer-group pricing) still need the store to compute the final price before sending.
  • Private products. The module syncs all published products by default. If some products should only be visible to specific user roles, you need to use the entity_sync_alter hook to filter them out.

The Module Structure

For developers who want to look at the code:

emporiqa/
  emporiqa.info.yml           # Module definition
  emporiqa.module             # Hook bridge (delegates to EmporiqaHooks)
  emporiqa.services.yml       # Service definitions
  emporiqa.install            # Install/uninstall, field auto-detection
  emporiqa.api.php            # Hook documentation
  drush.services.yml          # Drush command registration
  composer.json               # Dependencies
  config/
    install/                  # Default configuration
    optional/                 # Optional config
    schema/                   # Config schema
  js/                         # Widget embedding scripts
  src/
    Hook/                     # OOP entity hook handlers (EmporiqaHooks)
    EventSubscriber/          # Order completion events
    Plugin/
      QueueWorker/            # Async webhook delivery
    Form/                     # Settings + sync forms
    Controller/               # Order tracking, cart, user token
    Commands/                 # Drush commands (sync, test-connection)
    Service/                  # Webhook client, data formatting, sync
Enter fullscreen mode Exit fullscreen mode

Standard Drupal module layout. Service injection, plugin-based workers, YAML configuration. No custom tables, no schema alterations. It installs and uninstalls cleanly.

The module is an official module on drupal.org. Works with Drupal 10.3+ and 11.x, Commerce 2.40+ and 3.x, PHP 8.1+. If you want to try it on a real store, the sandbox syncs up to 100 products in about 2 minutes.

Top comments (0)