<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Rosen Hristov</title>
    <description>The latest articles on Forem by Rosen Hristov (@rosen_hristov).</description>
    <link>https://forem.com/rosen_hristov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3781164%2Fb0b97d2f-cfe4-44b5-a171-eb3c96852c7a.png</url>
      <title>Forem: Rosen Hristov</title>
      <link>https://forem.com/rosen_hristov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/rosen_hristov"/>
    <language>en</language>
    <item>
      <title>Building a Chat Assistant Module for Magento 2: Observers, Message Queues, and 10K Products</title>
      <dc:creator>Rosen Hristov</dc:creator>
      <pubDate>Fri, 27 Mar 2026 14:32:49 +0000</pubDate>
      <link>https://forem.com/rosen_hristov/building-a-chat-assistant-module-for-magento-2-observers-message-queues-and-10k-products-34jp</link>
      <guid>https://forem.com/rosen_hristov/building-a-chat-assistant-module-for-magento-2-observers-message-queues-and-10k-products-34jp</guid>
      <description>&lt;p&gt;Magento stores are large. Not WooCommerce "500 products with a few categories" large. Magento stores run 10,000+ SKUs, configurable products with dozens of variations, multiple store views for different languages, and Multi-Source Inventory across warehouses.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://emporiqa.com" rel="noopener noreferrer"&gt;Emporiqa&lt;/a&gt;, a chat assistant for e-commerce stores. After shipping integrations for &lt;a href="https://dev.to/rosen_hristov/building-a-chat-assistant-module-for-drupal-commerce-6e6"&gt;Drupal Commerce&lt;/a&gt;, WooCommerce, and Sylius, Magento was next. The catalog complexity made it the most interesting one to build.&lt;/p&gt;

&lt;p&gt;Here's how the module works under the hood.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Chat That Knows Nothing
&lt;/h2&gt;

&lt;p&gt;Most chat solutions for Magento are JavaScript widgets that sit on the page and know nothing about your catalog. Customer asks "do you have running shoes under 80 euros in size 42?" The widget either sends them to the search page or gives a generic response.&lt;/p&gt;

&lt;p&gt;To answer product questions, the chat assistant needs the actual catalog data: product names, descriptions, prices, stock levels, categories, images, and variation attributes. For Magento, it also needs to understand configurable product relationships and handle per-store-view translations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: Observers → Queue → Webhooks
&lt;/h2&gt;

&lt;p&gt;The module doesn't run chat processing inside Magento. That would tank admin performance. Instead, it sends product and page data to Emporiqa via webhooks, and the chat processing happens on separate infrastructure.&lt;/p&gt;

&lt;p&gt;The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Magento observer&lt;/strong&gt; fires on &lt;code&gt;catalog_product_save_after&lt;/code&gt;, &lt;code&gt;catalog_product_delete_before&lt;/code&gt;, &lt;code&gt;cms_page_save_after&lt;/code&gt;, etc.&lt;/li&gt;
&lt;li&gt;The observer &lt;strong&gt;formats the product&lt;/strong&gt; into a consolidated payload (all store view translations in one event)&lt;/li&gt;
&lt;li&gt;The event is &lt;strong&gt;published to Magento's DB message queue&lt;/strong&gt; (&lt;code&gt;emporiqa.webhook.consumer&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The queue consumer &lt;strong&gt;sends the webhook&lt;/strong&gt; to Emporiqa with HMAC-SHA256 signature&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The flow is non-blocking: the admin saves a product, the observer runs in milliseconds, and the actual HTTP request happens asynchronously via the queue consumer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configurable Products: Parent/Child Consolidation
&lt;/h2&gt;

&lt;p&gt;This was the tricky part. Magento's configurable products have a parent that holds the name, description, and images, plus child simple products that hold the actual SKU, price, and stock.&lt;/p&gt;

&lt;p&gt;The module syncs both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Parent (configurable)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;identification_number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product-123"&lt;/span&gt;
  &lt;span class="na"&gt;sku&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JACKET-BLUE"&lt;/span&gt;
  &lt;span class="na"&gt;is_parent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;variation_attributes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en-us"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Color"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Size"&lt;/span&gt;&lt;span class="pi"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;de-de"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Farbe"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Größe"&lt;/span&gt;&lt;span class="pi"&gt;]}}&lt;/span&gt;
  &lt;span class="na"&gt;names&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en-us"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Trail&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Jacket"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;de-de"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Wanderjacke"&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;

&lt;span class="na"&gt;Child (simple)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;identification_number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;variation-124"&lt;/span&gt;
  &lt;span class="na"&gt;sku&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JACKET-BLUE-M"&lt;/span&gt;
  &lt;span class="na"&gt;is_parent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;parent_sku&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JACKET-BLUE"&lt;/span&gt;
  &lt;span class="na"&gt;prices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EUR"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;current_price"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;79.99&lt;/span&gt;&lt;span class="pi"&gt;}]}&lt;/span&gt;
  &lt;span class="na"&gt;stock_quantities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;42&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the Emporiqa side, the assistant understands that "Trail Jacket" comes in multiple colors and sizes, with specific prices and stock per variation. A customer asking "do you have the trail jacket in medium?" gets an answer based on actual inventory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-Store-View Consolidation
&lt;/h2&gt;

&lt;p&gt;Magento uses store views for languages. A product might have an English name in the default store view and a German name in the &lt;code&gt;de_DE&lt;/code&gt; store view.&lt;/p&gt;

&lt;p&gt;Rather than sending one webhook per store view (which would multiply requests by the number of languages), the module consolidates everything into one payload per product:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"base"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"en-us"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Summer Jacket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"de-de"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sommerjacke"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"descriptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"base"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"en-us"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Lightweight jacket for warm days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"de-de"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Leichte Jacke für warme Tage"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The channel key &lt;code&gt;"base"&lt;/code&gt; is the Magento website code. If you have multiple websites (e.g., &lt;code&gt;base&lt;/code&gt;, &lt;code&gt;b2b&lt;/code&gt;), products nest under each website's code as the channel key.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ProductFormatter&lt;/code&gt; service handles this. It iterates over all enabled store views, loads the product in each store view's context, and merges the translations into one structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  MSI: Salable Quantity vs Physical Stock
&lt;/h2&gt;

&lt;p&gt;Magento's Multi-Source Inventory (MSI) tracks stock across multiple warehouses. Customers care about the &lt;em&gt;salable quantity&lt;/em&gt; (what's actually available to sell after reservations), not the physical stock at any individual source.&lt;/p&gt;

&lt;p&gt;The module uses MSI's &lt;code&gt;GetProductSalableQtyInterface&lt;/code&gt; when available and falls back to &lt;code&gt;StockRegistryInterface&lt;/code&gt; for stores not using MSI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified: the module checks for MSI availability at runtime&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$salableQty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;getProductSalableQty&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stockId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Exception&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fallback to legacy stock&lt;/span&gt;
    &lt;span class="nv"&gt;$stockItem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;stockRegistry&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getStockItemBySku&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sku&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$salableQty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$stockItem&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getQty&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This handles both Magento Open Source (which may not have MSI) and Adobe Commerce (which always does).&lt;/p&gt;

&lt;h2&gt;
  
  
  Message Queue: Why Not Direct HTTP
&lt;/h2&gt;

&lt;p&gt;I could have sent webhooks directly from the observer. Drupal's integration does something similar with queue workers. But Magento's observer system runs inside the request lifecycle, and making HTTP calls during &lt;code&gt;catalog_product_save_after&lt;/code&gt; would slow down admin operations.&lt;/p&gt;

&lt;p&gt;Magento's DB message queue solves this. The observer publishes a message, the consumer processes it later. For large catalog imports (think: importing 5,000 products via CSV), the queue absorbs the load and the consumer works through them at its own pace.&lt;/p&gt;

&lt;p&gt;The consumer is either started manually (&lt;code&gt;bin/magento queue:consumers:start emporiqa.webhook.consumer&lt;/code&gt;) or runs via Magento's cron-based consumer processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cart and Order Tracking
&lt;/h2&gt;

&lt;p&gt;The module exposes REST endpoints at &lt;code&gt;/rest/V1/emporiqa/cart/*&lt;/code&gt; for in-chat cart operations (add, remove, update, checkout URL). All use same-origin session cookies, no API tokens needed. The chat widget's &lt;code&gt;EmporiqaCartHandler&lt;/code&gt; JavaScript calls these automatically.&lt;/p&gt;

&lt;p&gt;For order tracking, a separate endpoint at &lt;code&gt;/rest/V1/emporiqa/order/tracking&lt;/code&gt; accepts HMAC-signed requests from Emporiqa. The customer must verify their billing email before any order data is returned. Requests older than 5 minutes are rejected.&lt;/p&gt;

&lt;h2&gt;
  
  
  CLI Commands for Automation
&lt;/h2&gt;

&lt;p&gt;Sync commands for deployment pipelines or scheduled jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/magento emporiqa:sync:products          &lt;span class="c"&gt;# Full product sync&lt;/span&gt;
bin/magento emporiqa:sync:pages             &lt;span class="c"&gt;# Full CMS page sync&lt;/span&gt;
bin/magento emporiqa:sync:all               &lt;span class="c"&gt;# Both&lt;/span&gt;
bin/magento emporiqa:test-connection         &lt;span class="c"&gt;# Verify webhook connectivity&lt;/span&gt;
bin/magento emporiqa:sync:products &lt;span class="nt"&gt;--dry-run&lt;/span&gt; &lt;span class="c"&gt;# Test without sending&lt;/span&gt;
bin/magento emporiqa:sync:products &lt;span class="nt"&gt;--batch-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;25
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each sync creates a session (&lt;code&gt;sync.start&lt;/code&gt; → batched events → &lt;code&gt;sync.complete&lt;/code&gt;). Items not seen during the session are marked as deleted on the Emporiqa side, so you don't need to manually delete stale products.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Doesn't Work Well
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Grouped and bundle products sync as standalone items without parent/child relationships.&lt;/strong&gt; The module handles simple and configurable products with full parent/child consolidation. Grouped and bundle product types sync their component products individually, without the grouping structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The queue consumer needs babysitting.&lt;/strong&gt; If &lt;code&gt;emporiqa.webhook.consumer&lt;/code&gt; dies (OOM, timeout, server restart), webhook events pile up silently. Magento's cron-based consumer processing is the safer option for production, but it adds latency. There's no built-in alerting when the queue backs up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CMS page sync is basic.&lt;/strong&gt; Magento's CMS pages are flat content blocks. The module syncs title, content, and URL, but can't handle Page Builder layouts or widget directives. Stores using complex Page Builder pages get the raw content, which isn't always useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;Three things stood out building this compared to the WooCommerce and Sylius integrations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;MSI detection is messy.&lt;/strong&gt; You can't assume MSI is present. Magento Open Source installs may have it removed. The module needs graceful fallback at runtime, not compile time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Configurable product observers fire for children too.&lt;/strong&gt; When saving a configurable product, Magento also saves each child. Without deduplication, you'd send the same parent product multiple times. The queue handles this with a simple check before publishing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Store view context switching is expensive.&lt;/strong&gt; Loading a product in store view A, then store view B, then back to A involves Magento's store emulation. The formatter batches all store views together to minimize context switches.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The module is on the &lt;a href="https://commercemarketplace.adobe.com/emporiqa-module-chat-assistant.html" rel="noopener noreferrer"&gt;Adobe Commerce Marketplace&lt;/a&gt; and supports Magento 2.4.4+ (both Open Source and Adobe Commerce).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require emporiqa/module-chat-assistant
bin/magento module:enable Emporiqa_ChatAssistant
bin/magento setup:upgrade
bin/magento cache:flush
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start with a &lt;a href="https://emporiqa.com/platform/create-store/" rel="noopener noreferrer"&gt;free sandbox store&lt;/a&gt; (100 products and 20 pages, no credit card). Sync some of your catalog, test with questions your customers ask, and see if the results make sense.&lt;/p&gt;

&lt;p&gt;Full setup guide: &lt;a href="https://emporiqa.com/docs/magento/" rel="noopener noreferrer"&gt;emporiqa.com/docs/magento/&lt;/a&gt;. I also wrote about the &lt;a href="https://emporiqa.com/blog/magento-adobe-commerce-chat-assistant-integration/" rel="noopener noreferrer"&gt;full Magento integration&lt;/a&gt; on the Emporiqa blog.&lt;/p&gt;

</description>
      <category>php</category>
      <category>magento</category>
      <category>webdev</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>From WooCommerce Product Save to 'Add to Cart' in a Chat Conversation</title>
      <dc:creator>Rosen Hristov</dc:creator>
      <pubDate>Thu, 26 Mar 2026 14:32:58 +0000</pubDate>
      <link>https://forem.com/rosen_hristov/from-woocommerce-product-save-to-add-to-cart-in-a-chat-conversation-216</link>
      <guid>https://forem.com/rosen_hristov/from-woocommerce-product-save-to-add-to-cart-in-a-chat-conversation-216</guid>
      <description>&lt;p&gt;A store owner saves a product in WooCommerce. Three seconds later, a customer on the other side of the world finds that product by typing "something warm for winter" into a chat widget, adds it to their cart, and checks out. The store owner sees the purchase attributed to the chat session on their dashboard.&lt;/p&gt;

&lt;p&gt;That's a lot of systems talking to each other. Here's what happens at each step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Product Save Triggers a Webhook
&lt;/h2&gt;

&lt;p&gt;The WooCommerce plugin hooks into &lt;code&gt;woocommerce_update_product&lt;/code&gt;. When the store owner clicks "Publish" or "Update," the hook fires and queues a webhook event in memory.&lt;/p&gt;

&lt;p&gt;The event isn't sent immediately. It goes into an in-memory queue that flushes on WordPress's &lt;code&gt;shutdown&lt;/code&gt; action, after the HTTP response is already sent to the browser. The admin page loads at normal speed. The webhook happens a fraction of a second later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified from the actual plugin&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'woocommerce_update_product'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'on_product_update'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shutdown'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'flush_webhook_queue'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The queue deduplicates. If a product triggers multiple hooks in one request (saving a variable product fires hooks for the parent and each variation), each product ID is queued once. The batch goes out as a single HMAC-signed POST to the webhook endpoint.&lt;/p&gt;

&lt;p&gt;The payload is a consolidated JSON structure — one event per product with all translations nested inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"events"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"product.updated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"identification_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"product-42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"product-42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"channels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Merino Wool Hiking Jacket"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"descriptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Warm, breathable, water-resistant..."&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"links"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://store.com/merino-wool-hiking-jacket"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"categories"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Outerwear &amp;gt; Jackets"&lt;/span&gt;&lt;span class="p"&gt;]}},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"brands"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TrailPeak"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"prices"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"current_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;149.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"regular_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;189.99&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"availability_statuses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"available"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"stock_quantities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"Material"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Merino Wool"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Weight"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"380g"&lt;/span&gt;&lt;span class="p"&gt;}}},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"images"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://store.com/jacket-front.jpg"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"is_parent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"parent_sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"variation_attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For multilingual stores (Polylang or WPML), the plugin sends all translations in a single event with nested language keys. A store with 3 languages and 1,000 products generates 1,000 events during a full sync, not 3,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Webhook Endpoint Validates and Queues
&lt;/h2&gt;

&lt;p&gt;The webhook hits the receiving endpoint. Five checks happen before anything is processed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Rate limit (120 requests/60s per store, sliding window in Redis)&lt;/li&gt;
&lt;li&gt;Store lookup&lt;/li&gt;
&lt;li&gt;Subscription check&lt;/li&gt;
&lt;li&gt;HMAC-SHA256 signature verification&lt;/li&gt;
&lt;li&gt;Pydantic schema validation on every event&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If all pass, the events go to a Celery task. The endpoint returns 202 and the store doesn't wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Embedding (or Skipping It)
&lt;/h2&gt;

&lt;p&gt;The Celery worker picks up the events. For each product, it computes a SHA-256 hash of the content that affects search quality (name, SKU, category, brand, description, attributes). If the hash matches what's already stored, and only the price or stock changed, the existing embedding is reused. No inference call needed.&lt;/p&gt;

&lt;p&gt;If the content actually changed, the product goes to the inference service for a new embedding. The model (&lt;code&gt;intfloat/multilingual-e5-large&lt;/code&gt;, 1024 dimensions) encodes the product into a vector that captures its meaning across 100+ languages.&lt;/p&gt;

&lt;p&gt;For a typical product update where only the price changed, the embedding step is skipped entirely. During a full resync of 10,000 products where maybe 50 descriptions changed, this saves thousands of inference calls.&lt;/p&gt;

&lt;p&gt;The product and its embedding get upserted to Qdrant, which stores both the vector and a BM25 index of the text fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: A Customer Types "Something Warm for Winter"
&lt;/h2&gt;

&lt;p&gt;A customer on the store opens the chat widget and types a query. The message goes to a LangGraph-based pipeline.&lt;/p&gt;

&lt;p&gt;First, a classifier looks at the message and decides which agents need to handle it. "Something warm for winter" goes to the product expert. "Something warm for winter, and what's your return policy?" goes to both the product expert and the customer support agent, running in parallel.&lt;/p&gt;

&lt;p&gt;The product expert runs a hybrid search:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BM25&lt;/strong&gt; searches the text fields for exact token matches. Good for brand names, SKUs, specific attributes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vector search&lt;/strong&gt; finds products that are semantically close to the query. "Something warm for winter" lands near jackets, sweaters, thermal gear, even though none of those product titles contain the words "warm" or "winter."&lt;/p&gt;

&lt;p&gt;Both result sets get normalized to the same score range and merged. A cross-encoder reranker (&lt;code&gt;ms-marco-MiniLM-L-6-v2&lt;/code&gt;) then compares each candidate directly against the query and re-sorts. The Merino Wool Hiking Jacket from step 1 scores high on both the vector similarity (warm + winter + outdoor) and the reranker.&lt;/p&gt;

&lt;p&gt;The agent formats a response with product recommendations, prices, and comparison notes. If multiple agents ran in parallel, an LLM merges their responses into one coherent reply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Customer Adds to Cart
&lt;/h2&gt;

&lt;p&gt;The customer sees the product card in the chat and clicks "Add to Cart." The widget calls &lt;code&gt;window.EmporiqaCartHandler({ action: 'add', items: [...] })&lt;/code&gt; on the store's frontend. This is a JavaScript function registered by the WooCommerce plugin that talks to WordPress AJAX endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;emporiqa_add_to_cart    → WC()-&amp;gt;cart-&amp;gt;add_to_cart()
emporiqa_get_cart       → current cart state
emporiqa_update_cart    → change quantity
emporiqa_remove_from_cart → remove item
emporiqa_clear_cart     → empty cart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin resolves Emporiqa's &lt;code&gt;identification_number&lt;/code&gt; format (&lt;code&gt;product-42&lt;/code&gt;, &lt;code&gt;variation-123&lt;/code&gt;) back to WooCommerce integer IDs. For variable products, it resolves the correct variation based on attributes. After every operation, the WooCommerce mini-cart in the page header refreshes automatically.&lt;/p&gt;

&lt;p&gt;The cart operations go through WooCommerce's own service layer. Store-specific rules (promotions, minimum quantities, inventory limits) still apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Checkout and Conversion Attribution
&lt;/h2&gt;

&lt;p&gt;The customer proceeds to checkout. The plugin hooks into &lt;code&gt;woocommerce_checkout_order_processed&lt;/code&gt; (and the block checkout equivalent, &lt;code&gt;woocommerce_store_api_checkout_order_processed&lt;/code&gt;) and reads the &lt;code&gt;emporiqa_sid&lt;/code&gt; cookie (the chat session ID, set when the widget opened). It saves this as order meta.&lt;/p&gt;

&lt;p&gt;When the order status changes to processing, completed, or on-hold (or &lt;code&gt;woocommerce_payment_complete&lt;/code&gt; fires), the plugin sends an &lt;code&gt;order.completed&lt;/code&gt; webhook with the session ID, line items, and totals. On the receiving end, this purchase gets attributed to the chat session that recommended the product.&lt;/p&gt;

&lt;p&gt;One problem I had to solve: external payment gateways (PayPal, Stripe) redirect server-to-server. The browser cookie is gone by the time the order status changes. The fix was capturing the session ID during checkout (when the browser is still present) and storing it as order meta. The payment completion hook then reads it from the database, not the cookie.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// During checkout (browser present)&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'woocommerce_checkout_order_processed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$_COOKIE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'emporiqa_sid'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wc_get_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_emporiqa_session_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;sanitize_text_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$_COOKIE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'emporiqa_sid'&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// On order status change or payment complete (server-to-server, no cookie)&lt;/span&gt;
&lt;span class="c1"&gt;// Hooks: woocommerce_order_status_processing, _completed, _on-hold, woocommerce_payment_complete&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'woocommerce_order_status_processing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wc_get_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$session_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_emporiqa_session_id'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Send order.completed webhook with session_id for attribution&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;_emporiqa_order_tracked&lt;/code&gt; meta flag prevents duplicate sends. Payment gateways sometimes fire status hooks multiple times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: The Dashboard
&lt;/h2&gt;

&lt;p&gt;The store owner opens the dashboard and sees:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chat sessions → cart adds → checkouts → purchases, with conversion rates at each step&lt;/li&gt;
&lt;li&gt;Revenue attributed to chat conversations&lt;/li&gt;
&lt;li&gt;Which products get recommended most, which get added to cart&lt;/li&gt;
&lt;li&gt;Widget engagement (loads, opens, unique visitors)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This closes the loop. Product save → webhook → embedding → search → agent response → cart → purchase → attribution → dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Extensible
&lt;/h2&gt;

&lt;p&gt;The WooCommerce plugin exposes WordPress filters for customization:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;emporiqa_should_sync_product&lt;/code&gt; / &lt;code&gt;emporiqa_should_sync_page&lt;/code&gt; to skip specific items&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;emporiqa_product_data&lt;/code&gt; / &lt;code&gt;emporiqa_variation_data&lt;/code&gt; to modify payloads before sending&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;emporiqa_order_tracking_data&lt;/code&gt; to add shipment tracking numbers to order lookups&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;emporiqa_widget_enabled&lt;/code&gt; to disable the widget on specific pages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Standard WordPress patterns, no forking needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;The conversion attribution relies on a cookie. If the customer clears cookies between chatting and checking out, the link breaks. There's no server-side fallback that connects the customer's identity across sessions. A logged-in user ID match would fix this, but it adds complexity for guest checkouts.&lt;/p&gt;

&lt;p&gt;I wrote about the &lt;a href="https://dev.to/rosen_hristov/hybrid-search-for-e-commerce-when-keywords-alone-fail-1ojg"&gt;hybrid search pipeline&lt;/a&gt;, the &lt;a href="https://dev.to/rosen_hristov/syncing-60000-products-without-breaking-everything-278c"&gt;sync architecture&lt;/a&gt;, and the &lt;a href="https://dev.to/rosen_hristov/why-i-split-one-langgraph-agent-into-four-running-in-parallel-2c65"&gt;parallel agent system&lt;/a&gt; separately. This post is about how they connect. The WooCommerce plugin is live on &lt;a href="https://wordpress.org/plugins/emporiqa/" rel="noopener noreferrer"&gt;wordpress.org&lt;/a&gt;. I covered the &lt;a href="https://emporiqa.com/blog/woocommerce-ai-chatbot-setup-guide-webhook/" rel="noopener noreferrer"&gt;full WooCommerce integration&lt;/a&gt; on the Emporiqa blog, and the &lt;a href="https://emporiqa.com/docs/woocommerce/" rel="noopener noreferrer"&gt;setup docs&lt;/a&gt; walk through the configuration step by step. You can test the pipeline end-to-end with a free &lt;a href="https://emporiqa.com/platform/create-store/" rel="noopener noreferrer"&gt;sandbox&lt;/a&gt; at &lt;a href="https://emporiqa.com" rel="noopener noreferrer"&gt;Emporiqa&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>webdev</category>
      <category>ecommerce</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>Building a Chat Module for PrestaShop: Hooks, Deferred Sending, and Combination Sync</title>
      <dc:creator>Rosen Hristov</dc:creator>
      <pubDate>Wed, 11 Mar 2026 06:44:45 +0000</pubDate>
      <link>https://forem.com/rosen_hristov/building-a-chat-module-for-prestashop-hooks-deferred-sending-and-combination-sync-12bl</link>
      <guid>https://forem.com/rosen_hristov/building-a-chat-module-for-prestashop-hooks-deferred-sending-and-combination-sync-12bl</guid>
      <description>&lt;p&gt;I already had webhook sync modules for &lt;a href="https://dev.to/rosen_hristov/building-a-chat-assistant-module-for-drupal-commerce-6e6"&gt;Drupal Commerce&lt;/a&gt; and &lt;a href="https://dev.to/rosen_hristov/building-a-sylius-plugin-with-webhook-sync-service-decoration-and-kernelterminate-oao"&gt;Sylius&lt;/a&gt;. Each platform handles entity events differently. PrestaShop has its own approach: hooks, no message queue, and a combination system that doesn't map cleanly to variants.&lt;/p&gt;

&lt;h2&gt;
  
  
  PrestaShop's Hook System
&lt;/h2&gt;

&lt;p&gt;PrestaShop uses hooks instead of event subscribers. For products, the key hooks are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;install&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;install&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registerHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actionProductSave'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registerHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actionProductDelete'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registerHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actionUpdateQuantity'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registerHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actionObjectCombinationAddAfter'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registerHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actionObjectCombinationUpdateAfter'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registerHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actionObjectCombinationDeleteAfter'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registerHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actionObjectCmsAddAfter'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registerHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actionObjectCmsUpdateAfter'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registerHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actionObjectCmsDeleteAfter'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registerHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'displayHeader'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registerHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actionValidateOrder'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registerHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actionOrderStatusPostUpdate'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last three hooks handle widget embedding and conversion tracking. &lt;code&gt;displayHeader&lt;/code&gt; auto-embeds the chat widget script on every front-office page. &lt;code&gt;actionValidateOrder&lt;/code&gt; and &lt;code&gt;actionOrderStatusPostUpdate&lt;/code&gt; handle the conversion pipeline (more on that below).&lt;/p&gt;

&lt;p&gt;Products and CMS pages use different hook naming patterns. Products get &lt;code&gt;actionProductSave&lt;/code&gt; (which fires on both create and update). CMS pages use the &lt;code&gt;actionObject*After&lt;/code&gt; pattern with separate hooks for add, update, and delete. Combinations also use the &lt;code&gt;actionObject*After&lt;/code&gt; pattern.&lt;/p&gt;

&lt;p&gt;This is different from Drupal's OOP hook pattern (&lt;code&gt;commerceProductInsert&lt;/code&gt; / &lt;code&gt;commerceProductUpdate&lt;/code&gt; / &lt;code&gt;commerceProductDelete&lt;/code&gt;), and from Sylius's &lt;code&gt;sylius.product.post_create&lt;/code&gt; resource events. In PrestaShop, you register hooks in &lt;code&gt;install()&lt;/code&gt; and implement them as class methods.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Deferred Sending Problem
&lt;/h2&gt;

&lt;p&gt;PrestaShop doesn't have a built-in message queue. Magento has &lt;code&gt;MessageQueuePublisher&lt;/code&gt;. Shopware has Symfony Messenger. Sylius has &lt;code&gt;kernel.terminate&lt;/code&gt;. PrestaShop has none of these.&lt;/p&gt;

&lt;p&gt;Sending webhooks synchronously inside a hook would slow down every product save in the admin. Saving a product with 10 combinations might trigger 11 hook calls. If each one made an HTTP request, the admin would freeze.&lt;/p&gt;

&lt;p&gt;The solution: deferred sending with &lt;code&gt;register_shutdown_function&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmporiqaWebhookClient&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$pendingEvents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$shutdownRegistered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;queueEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pendingEvents&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'data'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;shutdownRegistered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;register_shutdown_function&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'flushPendingEvents'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;shutdownRegistered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;flushPendingEvents&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;function_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'fastcgi_finish_request'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;fastcgi_finish_request&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Now send all queued events&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pendingEvents&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;register_shutdown_function&lt;/code&gt; runs after PHP finishes the main request. &lt;code&gt;fastcgi_finish_request()&lt;/code&gt; (available on PHP-FPM) sends the response to the browser immediately, so the admin page loads while webhooks fire in the background.&lt;/p&gt;

&lt;p&gt;This is the same concept as Sylius's &lt;code&gt;kernel.terminate&lt;/code&gt;, but adapted for PrestaShop's architecture. Both achieve the same goal: don't make the user wait for HTTP requests they don't need to see.&lt;/p&gt;

&lt;h3&gt;
  
  
  Comparison: How Each Platform Handles Async Delivery
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;When it runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Magento&lt;/td&gt;
&lt;td&gt;DB message queue + consumer&lt;/td&gt;
&lt;td&gt;Background worker process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shopware&lt;/td&gt;
&lt;td&gt;Symfony Messenger + async transport&lt;/td&gt;
&lt;td&gt;Background worker process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sylius&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kernel.terminate&lt;/code&gt; event&lt;/td&gt;
&lt;td&gt;After response sent, same process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PrestaShop&lt;/td&gt;
&lt;td&gt;&lt;code&gt;register_shutdown_function&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;After response sent, same process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drupal&lt;/td&gt;
&lt;td&gt;Queue API + cron worker&lt;/td&gt;
&lt;td&gt;Background cron process&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sylius and PrestaShop both run in the same PHP process after the response. Magento, Shopware, and Drupal use separate worker processes. The tradeoff: same-process is simpler (no worker to manage) but ties webhook delivery to the web request lifecycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deduplication
&lt;/h2&gt;

&lt;p&gt;Saving a product with combinations can trigger &lt;code&gt;actionProductSave&lt;/code&gt; multiple times in one request. Without dedup, you'd send the same product data three times.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$queuedProductIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$queuedPageIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;hookActionProductSave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$productId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id_product'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;queuedProductIds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$productId&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;queuedProductIds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$productId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;webhookService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;queueEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'product.updated'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;formatProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$productId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple array tracking per request. Product IDs and page IDs are tracked separately. This prevents the deferred queue from containing duplicate events for the same entity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combination Handling
&lt;/h2&gt;

&lt;p&gt;PrestaShop's combination system is the trickiest part. A product can have combinations (Color: Red + Size: M, Color: Blue + Size: L, etc.). These are separate database records linked to the parent product.&lt;/p&gt;

&lt;p&gt;The external service stores products with all variations in one record. So combination changes need to trigger a parent product re-sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;hookActionObjectCombinationUpdateAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$combination&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;$productId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$combination&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id_product&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Re-sync the parent product (which includes all combinations)&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;syncProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$productId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;hookActionObjectCombinationDeleteAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$combination&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;$productId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$combination&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id_product&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Send delete event for the specific variation&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;webhookService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;queueEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'product.deleted'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'identification_number'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'variation-'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$combination&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="c1"&gt;// Re-sync the parent so it reflects the remaining combinations&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;syncProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$productId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deleting a combination requires two actions: a delete event for the variation itself, and a re-sync of the parent product so the external service knows which combinations remain. This is different from Magento's configurable products or Sylius's product variants, but the end result is the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Customization Hooks
&lt;/h2&gt;

&lt;p&gt;PrestaShop doesn't have Symfony's service decoration or Magento's DI preferences. Instead, I used PrestaShop's own hook system for customization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;hookActionEmporiqaFormatProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Other modules can modify product data before it's sent&lt;/span&gt;
    &lt;span class="c1"&gt;// $params['data'] is passed by reference (also receives 'product' and 'event_type')&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;hookActionEmporiqaShouldSyncProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Other modules can prevent a product from syncing&lt;/span&gt;
    &lt;span class="c1"&gt;// Set $params['should_sync'] = false to skip&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven hooks total: &lt;code&gt;actionEmporiqaShouldSyncProduct&lt;/code&gt;, &lt;code&gt;actionEmporiqaShouldSyncPage&lt;/code&gt;, &lt;code&gt;actionEmporiqaFormatProduct&lt;/code&gt;, &lt;code&gt;actionEmporiqaFormatPage&lt;/code&gt;, &lt;code&gt;actionEmporiqaFormatOrder&lt;/code&gt;, &lt;code&gt;actionEmporiqaOrderTracking&lt;/code&gt;, &lt;code&gt;actionEmporiqaWidgetParams&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is PrestaShop's equivalent of Drupal's alter hooks, Sylius's service decoration, and Magento's DI preferences. Each platform uses its own extensibility mechanism. The goal is the same: let other code modify behavior without editing the module.&lt;/p&gt;

&lt;h2&gt;
  
  
  Admin Sync UI (No CLI)
&lt;/h2&gt;

&lt;p&gt;PrestaShop doesn't have a standard CLI framework like Drush (Drupal), WP-CLI (WordPress), or &lt;code&gt;bin/console&lt;/code&gt; (Symfony/Shopware). Magento has &lt;code&gt;bin/magento&lt;/code&gt;. PrestaShop has nothing equivalent.&lt;/p&gt;

&lt;p&gt;The initial sync uses an AJAX-powered admin UI instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click "Start Sync"&lt;/li&gt;
&lt;li&gt;JavaScript calls the module's admin controller with &lt;code&gt;action=init&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Controller returns total product/page count&lt;/li&gt;
&lt;li&gt;JavaScript calls &lt;code&gt;action=batch&lt;/code&gt; repeatedly with offset/limit&lt;/li&gt;
&lt;li&gt;Progress bar updates after each batch&lt;/li&gt;
&lt;li&gt;Final call to &lt;code&gt;action=complete&lt;/code&gt; triggers reconciliation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This approach works well for PrestaShop's audience. Store owners are more likely to click a button in the admin than SSH into a server and run a CLI command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cart Operations
&lt;/h2&gt;

&lt;p&gt;PrestaShop doesn't have a REST API like Magento's &lt;code&gt;rest/V1/&lt;/code&gt; or WooCommerce's &lt;code&gt;/wp-json/wc/&lt;/code&gt;. For cart operations (add to cart, update quantity, remove, checkout), I used a front controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// modules/emporiqa/controllers/front/cartapi.php&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmporiqaCartapiModuleFrontController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;ModuleFrontController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;postProcess&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tools&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'action'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s1"&gt;'add'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addToCart&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'remove'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;removeFromCart&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'update'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;updateQuantity&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'clear'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;clearCart&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'get'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getCart&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'checkout-url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getCheckoutUrl&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;respondError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Unknown action'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chat widget's JavaScript calls these endpoints. Front controllers are PrestaShop's standard way to expose custom URLs from a module.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conversion Tracking
&lt;/h2&gt;

&lt;p&gt;The module tracks whether a chat session led to a purchase. When a customer places an order, &lt;code&gt;actionValidateOrder&lt;/code&gt; fires and the module captures the chat session cookie from the request, storing the order-to-session mapping in a custom database table. Later, when the order reaches a completed status (payment accepted, delivered, etc.), &lt;code&gt;actionOrderStatusPostUpdate&lt;/code&gt; fires and sends an &lt;code&gt;order.completed&lt;/code&gt; webhook with the session ID, order total, and currency. This lets the external service attribute revenue to specific chat conversations without polling the store for order data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Doesn't Work Well
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No background worker:&lt;/strong&gt; The shutdown function approach works, but it means webhook delivery depends on the PHP process completing. If PHP times out or crashes, queued events are lost. Platforms with proper message queues (Magento, Shopware) handle this better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hook naming inconsistency:&lt;/strong&gt; Products use &lt;code&gt;actionProductSave&lt;/code&gt; while CMS pages use &lt;code&gt;actionObjectCmsUpdateAfter&lt;/code&gt;. Combinations use &lt;code&gt;actionObjectCombinationUpdateAfter&lt;/code&gt;. Three different patterns for the same concept. You just have to know which pattern each entity uses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No built-in REST API:&lt;/strong&gt; Every AJAX endpoint requires a front controller. Magento's &lt;code&gt;webapi.xml&lt;/code&gt; and Shopware's route annotations are much cleaner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaway
&lt;/h2&gt;

&lt;p&gt;Every platform has its own way of handling entity events, async processing, and extensibility. PrestaShop's approach (hooks + shutdown function + front controllers) is older and less structured than Symfony-based or Magento-based alternatives, but it works. The module syncs products and pages reliably, handles combinations correctly, and doesn't slow down the admin.&lt;/p&gt;

&lt;p&gt;The full module is available for PrestaShop 8.0+ / 9.0+ with PHP 7.4+ on the &lt;a href="https://addons.prestashop.com/en/front-office-features-prestashop-modules/97345-emporiqa-chat-assistant.html" rel="noopener noreferrer"&gt;PrestaShop Addons Marketplace&lt;/a&gt;, or free when you &lt;a href="https://emporiqa.com/platform/create-store/" rel="noopener noreferrer"&gt;create an Emporiqa account&lt;/a&gt;. &lt;a href="https://emporiqa.com/docs/prestashop/" rel="noopener noreferrer"&gt;Documentation&lt;/a&gt;. I wrote more about the &lt;a href="https://emporiqa.com/blog/prestashop-chat-assistant-integration/" rel="noopener noreferrer"&gt;full PrestaShop integration story&lt;/a&gt; on the Emporiqa blog.&lt;/p&gt;

</description>
      <category>php</category>
      <category>prestashop</category>
      <category>webdev</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Your Drupal Commerce Store Serves 6 Languages. Your Chat Handles One.</title>
      <dc:creator>Rosen Hristov</dc:creator>
      <pubDate>Wed, 04 Mar 2026 12:46:54 +0000</pubDate>
      <link>https://forem.com/rosen_hristov/your-drupal-commerce-store-serves-6-languages-your-chat-handles-one-24n8</link>
      <guid>https://forem.com/rosen_hristov/your-drupal-commerce-store-serves-6-languages-your-chat-handles-one-24n8</guid>
      <description>&lt;p&gt;I already had a &lt;a href="https://dev.to/rosen_hristov/building-a-chat-assistant-module-for-drupal-commerce-6e6"&gt;webhook sync module for Drupal Commerce&lt;/a&gt;. Products synced, the chat widget worked, customers could search and get answers. Then a store in Norway asked me why the assistant was answering in English when their site was in Norwegian.&lt;/p&gt;

&lt;p&gt;The module was syncing products. But only the default language. A store with translations in Norwegian, Swedish, Danish, and Finnish was sending English-only product data to the chat service. The assistant searched English descriptions and responded in English regardless of the store view. Their entire investment in translations was invisible to the chat.&lt;/p&gt;

&lt;p&gt;I spent two weeks fixing this. Here's what "multilingual chat" means, technically.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Translations Work in Drupal Commerce
&lt;/h2&gt;

&lt;p&gt;Drupal Commerce uses the Content Translation module. A product entity has a canonical version (usually English) with translation sets for each enabled language. Each translation carries its own title, description, meta description, and any translatable custom fields.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Product: Winter Jacket (node/42)
├── en: "Winter Jacket" / "Waterproof, breathable..."
├── no: "Vinterjakke" / "Vanntett, pustende..."
├── sv: "Vinterjacka" / "Vattentät, andningsbar..."
└── da: "Vinterjakke" / "Vandtæt, åndbar..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drupal stores these as separate field_data rows keyed by language code. When you load a product with a specific language context, you get that language's field values with automatic fallback to the default if a translation is missing.&lt;/p&gt;

&lt;p&gt;For Commerce stores with product variations, the numbers multiply. A jacket with 3 colors and 4 sizes means 12 variations. Each variation can have its own translated title and attributes. Across 4 languages, that's 48 translation sets for one product. A store with 5,000 products in 4 languages can easily have 100,000+ translated field entries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Layers of "Multilingual"
&lt;/h2&gt;

&lt;p&gt;When a vendor says "multilingual support," ask which layer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: Widget UI.&lt;/strong&gt; The chat interface (buttons, placeholders, system messages) displays in the visitor's language. This is the easy part. Load a translated string file, switch based on Drupal's current language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: Search index.&lt;/strong&gt; When a Norwegian customer searches for "vinterjakke," the search needs to hit Norwegian product data. If the index only contains English descriptions, the search either returns nothing (the word "vinterjakke" doesn't appear in English text) or returns irrelevant results from fuzzy matching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3: Response language.&lt;/strong&gt; The LLM composes its answer in Norwegian, using Norwegian product names and descriptions as source material. If it pulls from the English index and translates on the fly, the product names won't match what the customer sees on the store.&lt;/p&gt;

&lt;p&gt;Most chat tools get Layer 1 right but stop there, leaving Layers 2 and 3 unaddressed. They translate the UI, search a single English index, and hope the LLM's output translation is good enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Module Handles This
&lt;/h2&gt;

&lt;p&gt;The first version of my Drupal module sent one webhook per product containing only the default language fields. The fix was to iterate over all enabled languages, load each translation, and nest them in a single payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"product.updated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"identification_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PROD-42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WJ-001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"channels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Winter Jacket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"no"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Vinterjakke"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sv"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Vinterjacka"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"da"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Vinterjakke"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"descriptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Waterproof, breathable..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"no"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Vanntett, pustende..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sv"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Vattentät, andningsbar..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"da"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Vandtæt, åndbar..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"prices"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"current_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;89.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"regular_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;119.99&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"availability_statuses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"available"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stock_quantities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The channel key &lt;code&gt;"1"&lt;/code&gt; is the Drupal Commerce store ID. Stores with multiple Commerce stores would use each store's ID as the channel key, each with their own prices and availability. All translatable fields (names, descriptions, links, attributes) nest languages inside the channel key.&lt;/p&gt;

&lt;p&gt;One HTTP request per product instead of four. On the receiving side, Emporiqa builds a separate search index for each language. Norwegian product descriptions go into the Norwegian index, Swedish into the Swedish index.&lt;/p&gt;

&lt;p&gt;The module reads enabled languages from Drupal's &lt;code&gt;LanguageManagerInterface&lt;/code&gt; and checks which translations exist for each product. Missing translations fall back to the default language rather than sending empty fields.&lt;/p&gt;

&lt;p&gt;The widget embed tag carries the language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;async&lt;/span&gt;
  &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://emporiqa.com/chat/embed/?store_id=STORE_ID&amp;amp;language=no"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the Drupal module, the language code comes from the current language context via &lt;code&gt;\Drupal::languageManager()-&amp;gt;getCurrentLanguage()&lt;/code&gt;. A visitor on the Norwegian store view gets &lt;code&gt;language=no&lt;/code&gt;, the widget loads Norwegian UI text, searches the Norwegian index, and the LLM responds in Norwegian.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-Language Search
&lt;/h2&gt;

&lt;p&gt;A Bulgarian tourist visits a Norwegian store and types "зимно яке" (Bulgarian for winter jacket). The products are in Norwegian.&lt;/p&gt;

&lt;p&gt;Keyword matching fails completely. "зимно яке" shares zero characters with "Vinterjakke." But multilingual embedding models map both phrases to nearby points in vector space because they mean the same thing. The vector search returns the Norwegian winter jacket without any explicit translation step.&lt;/p&gt;

&lt;p&gt;I use multilingual embeddings that cover 65+ languages. The same model encodes both the Norwegian product descriptions and the Bulgarian query into vectors in a shared space. Semantic similarity works across language boundaries.&lt;/p&gt;

&lt;p&gt;A German store serving Austria and Switzerland will have customers who also speak Turkish, Croatian, Italian, or Serbian. The chat handles all of them without the store needing translations in each of those languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Doesn't Work Well
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Partial translations&lt;/strong&gt; are the biggest issue. A product with English and Norwegian translations but no Swedish version means Swedish customers see the English fallback. The assistant doesn't warn them it's showing content in a different language. It returns what it found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing field translations&lt;/strong&gt; create a subtler problem. Some stores translate product titles but not descriptions. The search finds the product by its Norwegian title, but the description comes back in English. The response mixes languages. Confusing for the customer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First-message language mismatch.&lt;/strong&gt; The widget language is set by the embed tag based on Drupal's current store view. If an English-speaking tourist on a Norwegian site types in English, the system detects the switch after processing the first message. That first response may come back in Norwegian before it adapts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CMS page gaps.&lt;/strong&gt; If your return policy only exists in English, a French customer asking about returns gets English text. Technically accurate, but not a great experience. The fix is simple (translate your key policy pages), but many stores skip this for low-traffic languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Practical Takeaway
&lt;/h2&gt;

&lt;p&gt;Multilingual chat is only as good as your translation data. Full translations across all products and pages give the best results. Partial translations work but produce mixed-language responses. No translations mean everyone gets the default language regardless of the store view.&lt;/p&gt;

&lt;p&gt;If you're running a multilingual Drupal Commerce store, the question worth asking about any chat tool is: "does it search the right language index and respond in the right language with the right product names?"&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.drupal.org/project/emporiqa" rel="noopener noreferrer"&gt;Emporiqa module on drupal.org&lt;/a&gt; handles translation sync, language detection, and widget embedding for Drupal 10+. I go deeper into &lt;a href="https://emporiqa.com/blog/multilingual-ecommerce-ai-chatbot-64-languages/" rel="noopener noreferrer"&gt;how the multilingual pipeline works&lt;/a&gt; on the Emporiqa blog. The full setup guide is at &lt;a href="https://emporiqa.com/docs/drupal/" rel="noopener noreferrer"&gt;emporiqa.com/docs/drupal/&lt;/a&gt;. You can test multilingual search with a free &lt;a href="https://emporiqa.com/platform/create-store/" rel="noopener noreferrer"&gt;sandbox&lt;/a&gt; (100 products, no credit card).&lt;/p&gt;

</description>
      <category>drupal</category>
      <category>php</category>
      <category>webdev</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Building a Sylius Plugin with Webhook Sync, Service Decoration, and kernel.terminate</title>
      <dc:creator>Rosen Hristov</dc:creator>
      <pubDate>Fri, 27 Feb 2026 09:38:08 +0000</pubDate>
      <link>https://forem.com/rosen_hristov/building-a-sylius-plugin-with-webhook-sync-service-decoration-and-kernelterminate-oao</link>
      <guid>https://forem.com/rosen_hristov/building-a-sylius-plugin-with-webhook-sync-service-decoration-and-kernelterminate-oao</guid>
      <description>&lt;p&gt;I already had a &lt;a href="https://dev.to/rosen_hristov/building-a-chat-assistant-module-for-drupal-commerce-6e6"&gt;webhook sync module for Drupal Commerce&lt;/a&gt;. Drupal uses entity hooks, queue workers, and alter hooks. For Sylius, none of that applies.&lt;/p&gt;

&lt;p&gt;Sylius sits on Symfony. That means event subscribers, service decoration, console commands, and &lt;code&gt;kernel.terminate&lt;/code&gt;. The plugin needed to feel like a Symfony bundle, not a ported Drupal module.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Event Systems for Two Entity Types
&lt;/h2&gt;

&lt;p&gt;Products in Sylius are proper Sylius resources. They fire resource events: &lt;code&gt;sylius.product.post_create&lt;/code&gt;, &lt;code&gt;sylius.product.post_update&lt;/code&gt;, &lt;code&gt;sylius.product_variant.post_create&lt;/code&gt;, etc. An event subscriber catches these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductEventSubscriber&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;EventSubscriberInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getSubscribedEvents&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'sylius.product.post_create'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'onProductCreate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'sylius.product.post_update'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'onProductUpdate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'sylius.product.pre_delete'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'onProductDelete'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'sylius.product_variant.post_create'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'onVariantCreate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'sylius.product_variant.post_update'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'onVariantUpdate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'sylius.product_variant.pre_delete'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'onVariantDelete'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a variant changes, the subscriber re-syncs the entire parent product. The external service stores products with all their variations in one record, so a variant update means the whole product needs refreshing.&lt;/p&gt;

&lt;p&gt;Pages work differently. Sylius has no built-in Page entity. Stores use BitBag CMS, custom entities, or nothing. These aren't Sylius resources, so they don't fire resource events. I used Doctrine lifecycle listeners instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[AsDoctrineListener(event: Events::postPersist)]&lt;/span&gt;
&lt;span class="na"&gt;#[AsDoctrineListener(event: Events::postUpdate)]&lt;/span&gt;
&lt;span class="na"&gt;#[AsDoctrineListener(event: Events::preRemove)]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PageDoctrineListener&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;postPersist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PostPersistEventArgs&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$entity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getObject&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isTrackedPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'page.created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The listener only fires if the entity implements &lt;code&gt;PageInterface&lt;/code&gt; and its class is listed in the bundle config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;emporiqa&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;page_entity_classes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;App\Entity\Page&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PageInterface&lt;/code&gt; is minimal — &lt;code&gt;getId()&lt;/code&gt; and &lt;code&gt;getTranslations()&lt;/code&gt;. Existing page entities from any CMS plugin can implement it without changing their schema.&lt;/p&gt;

&lt;p&gt;This was a design tradeoff. I could have required a specific page entity structure, which would make the plugin easier to build but harder to adopt. Or I could make page sync entirely optional and interface-based, which is what I did. Most stores don't have pages at all, and the ones that do all structure them differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deferred Sending via kernel.terminate
&lt;/h2&gt;

&lt;p&gt;Sending HTTP requests during an admin save action is slow. The Drupal module uses queue workers. For Symfony, I used &lt;code&gt;kernel.terminate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Events aren't sent immediately when they fire. They go into a &lt;code&gt;WebhookEventQueue&lt;/code&gt; — an in-request buffer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WebhookEventQueue&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;EventSubscriberInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'identification_number'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="c1"&gt;// Delete always wins&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str_ends_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'.deleted'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'data'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Don't overwrite a delete with an update&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;str_ends_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'.deleted'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'data'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The queue deduplicates by &lt;code&gt;identification_number&lt;/code&gt;. If a product is saved twice in one request (bulk operations), only the last event goes out. All translations are consolidated into a single event per product. Delete always wins over create/update — if something is deleted, there's no point sending the create that preceded it.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;kernel.terminate&lt;/code&gt;, after the HTTP response is already sent to the browser, the queue flushes and sends everything in one batch. The admin UI stays fast. The webhook happens a few hundred milliseconds later.&lt;/p&gt;

&lt;p&gt;This doesn't work for CLI commands though — there's no HTTP kernel in a console context. The sync commands send batches directly via &lt;code&gt;WebhookSender&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service Decoration for Customization
&lt;/h2&gt;

&lt;p&gt;Where Drupal uses alter hooks, Symfony has service decoration.&lt;/p&gt;

&lt;p&gt;The plugin formats products into webhook payloads via &lt;code&gt;ProductFormatter&lt;/code&gt;. If a store has custom attributes (warranty, vehicle compatibility, material composition), the store developer decorates the formatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CustomProductFormatter&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ProductFormatterInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;ProductFormatterInterface&lt;/span&gt; &lt;span class="nv"&gt;$inner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ProductInterface&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$events&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'warranty_years'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// Attributes use {channel: {language: {name: value}}} nesting&lt;/span&gt;
                &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'data'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'attributes'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$channel&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$languages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$languages&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$lang&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$attrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="nv"&gt;$attrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'warranty'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;
                            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAttributeByCodeAndLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'warranty_years'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                            &lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' years'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$events&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it in &lt;code&gt;services.yaml&lt;/code&gt; with a &lt;code&gt;#[AsDecorator]&lt;/code&gt; attribute or the standard Symfony &lt;code&gt;decorates&lt;/code&gt; key. The original formatter becomes &lt;code&gt;$inner&lt;/code&gt;, and your code wraps it.&lt;/p&gt;

&lt;p&gt;For cases where you need to skip sync for specific entities or modify the payload batch before it's sent, the plugin dispatches Symfony events:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;emporiqa.pre_sync&lt;/code&gt; (cancellable, fired before any entity syncs)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;emporiqa.post_format&lt;/code&gt; (fired after formatting, you can modify the payload before queuing)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;emporiqa.pre_webhook_send&lt;/code&gt; (fired before the HTTP batch, you can modify or filter events)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;emporiqa.order_tracking&lt;/code&gt; (fired after order lookup, you can modify the response)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;emporiqa.cart_operation&lt;/code&gt; (fired before a cart operation, cancellable)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Service decoration for structural changes, event listeners for conditional logic — both standard Symfony.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cart Operations via Sylius's Own Services
&lt;/h2&gt;

&lt;p&gt;The plugin provides cart API endpoints that the chat widget calls. The implementation uses Sylius's native services:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CartContextInterface&lt;/code&gt; to get the current cart&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OrderModifierInterface&lt;/code&gt; to add items&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OrderItemQuantityModifierInterface&lt;/code&gt; to update quantities
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET  /emporiqa/api/cart           → current cart state
POST /emporiqa/api/cart/add       → add item by variation_id
POST /emporiqa/api/cart/update    → update quantity
POST /emporiqa/api/cart/remove    → remove item
POST /emporiqa/api/cart/clear     → empty cart
GET  /emporiqa/api/cart/checkout-url → redirect URL
GET  /emporiqa/api/user-token        → signed user token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No custom cart logic. The plugin delegates to whatever Sylius is already doing for cart management. If the store has custom cart rules (promotions, inventory checks), they still apply because the plugin goes through Sylius's own service layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conversion Tracking
&lt;/h2&gt;

&lt;p&gt;An event subscriber listens for Symfony Workflow events on order completion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getSubscribedEvents&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'workflow.sylius_order_checkout.completed.complete'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'onOrderComplete'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a checkout completes, the subscriber reads an &lt;code&gt;emporiqa_sid&lt;/code&gt; cookie (the chat session ID, set by the widget JavaScript), assembles an &lt;code&gt;order.completed&lt;/code&gt; webhook payload with line items, totals, and currency, and queues it via &lt;code&gt;WebhookEventQueue&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On the receiving end, this links the chat session to the purchase. The dashboard shows chat-attributed revenue.&lt;/p&gt;

&lt;p&gt;The subscriber works with both Sylius 2.x (Symfony Workflow events) and 1.x (Winzou State Machine callbacks), so conversion tracking is available regardless of which version the store runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Doesn't Work Well
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Page sync is opt-in and requires effort.&lt;/strong&gt; Since Sylius has no standard Page entity, every store needs to implement &lt;code&gt;PageInterface&lt;/code&gt; on whatever they're using for content. Some stores skip page sync entirely because the effort isn't worth it for a handful of policy pages. I provide samples in the README, but it's still manual work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;PageUrlResolver&lt;/code&gt; is a stub.&lt;/strong&gt; The plugin can't know how a store routes to its page entities. The default resolver returns empty strings. Stores need to override it with a service that knows their routing. This is the most common "it doesn't work" issue I hear about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CLI sync doesn't use &lt;code&gt;kernel.terminate&lt;/code&gt;.&lt;/strong&gt; Console commands send batches directly, which means the deduplication logic in &lt;code&gt;WebhookEventQueue&lt;/code&gt; doesn't apply. The sync commands handle batching themselves with a different code path. Two sending mechanisms for the same data, which isn't ideal.&lt;/p&gt;

&lt;p&gt;The plugin is on Packagist: &lt;a href="https://packagist.org/packages/emporiqa/sylius-plugin" rel="noopener noreferrer"&gt;emporiqa/sylius-plugin&lt;/a&gt;. Sylius ^1.12 or ^2.0, PHP 8.1+. The &lt;a href="https://emporiqa.com/docs/sylius/" rel="noopener noreferrer"&gt;setup docs&lt;/a&gt; walk through installation and configuration. I also wrote about the &lt;a href="https://emporiqa.com/blog/sylius-ai-chatbot-symfony-bundle-integration/" rel="noopener noreferrer"&gt;full integration story&lt;/a&gt; on the Emporiqa blog. You can see the product sync and cart working with a free &lt;a href="https://emporiqa.com/platform/create-store/" rel="noopener noreferrer"&gt;sandbox&lt;/a&gt; at &lt;a href="https://emporiqa.com" rel="noopener noreferrer"&gt;Emporiqa&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>webdev</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Your Chatbot Recommends Products You Don't Sell</title>
      <dc:creator>Rosen Hristov</dc:creator>
      <pubDate>Thu, 26 Feb 2026 12:47:47 +0000</pubDate>
      <link>https://forem.com/rosen_hristov/your-chatbot-recommends-products-you-dont-sell-3jnl</link>
      <guid>https://forem.com/rosen_hristov/your-chatbot-recommends-products-you-dont-sell-3jnl</guid>
      <description>&lt;p&gt;I was testing my product agent on a demo store that sells electronics. I typed "do you have leather jackets?" The agent searched, got zero results, and instead of admitting the store doesn't carry jackets, it generated product recommendations from its training data. Product names, prices, descriptions that weren't in the store.&lt;/p&gt;

&lt;p&gt;This is what happens when a ReAct agent with a search tool has no concept of what the store actually sells. It searches, gets nothing useful, and fills the gap with whatever the LLM thinks a helpful response should contain.&lt;/p&gt;

&lt;p&gt;I tried prompt engineering first. "Only recommend products from search results." "Never invent products." "If search returns no results, say so." It helped with the simple cases but broke down on subtler ones. The agent would find vaguely related products and stretch the recommendation to fit the query.&lt;/p&gt;

&lt;p&gt;Prompt engineering wasn't going to fix this. The agent needed a model of the store's inventory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Agent Has No Inventory Model
&lt;/h2&gt;

&lt;p&gt;A typical product search agent works like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Customer asks a question&lt;/li&gt;
&lt;li&gt;Agent decides to search&lt;/li&gt;
&lt;li&gt;Search returns results (or doesn't)&lt;/li&gt;
&lt;li&gt;Agent generates a response&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The agent knows nothing about the store until after it searches. If the customer asks about a category that doesn't exist, the agent can't know that in advance. It searches, gets poor results, and has to improvise a response. That's where hallucinations come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the Catalog First
&lt;/h2&gt;

&lt;p&gt;I added a preprocessing step between the customer message and the agent. Before the agent runs, the preprocessor loads the store's actual catalog metadata and analyzes the query against it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StoreContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;            &lt;span class="c1"&gt;# ["Smartphones", "Laptops", "Headphones"]
&lt;/span&gt;    &lt;span class="n"&gt;brands&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                &lt;span class="c1"&gt;# ["Apple", "Samsung", "Sony"]
&lt;/span&gt;    &lt;span class="n"&gt;total_products&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;              &lt;span class="c1"&gt;# 847
&lt;/span&gt;    &lt;span class="n"&gt;category_counts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# {"Smartphones": 234, "Laptops": 156, ...}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The context comes from the database, cached per store. The preprocessor gives this to a fast LLM along with the customer's query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Store categories (product count): Smartphones: 234, Laptops: 156, Headphones: 89, ...
Store brands: Apple, Samsung, Sony, ...

Query: "do you have leather jackets?"
Context: First message
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM returns a structured response with one of four actions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;no_match&lt;/strong&gt;: Store doesn't carry this. Respond directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;show_products&lt;/strong&gt;: Small category (10 or fewer products). Fetch and display them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;qualify&lt;/strong&gt;: Ambiguous query. Ask a clarifying question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;search&lt;/strong&gt;: Pass to the full agent with pre-extracted filters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For "leather jackets" on an electronics store, the preprocessor returns &lt;code&gt;no_match&lt;/code&gt; with a response mentioning what the store does carry. The agent never runs, and there's nothing to hallucinate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Paths
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;no_match&lt;/strong&gt; catches impossible queries early. "Do you have ski equipment?" on a bookstore. The preprocessor sees the categories (Fiction, Non-fiction, Children's, Academic), confirms ski equipment isn't among them, and says so. Saves an agent invocation and prevents the agent from trying to stretch book recommendations into ski gear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;show_products&lt;/strong&gt; handles small categories directly. "What teas do you have?" on a store with 6 teas. Instead of running the full search pipeline (embed the query, retrieve candidates, rerank, invoke the agent), the preprocessor fetches all 6 products and presents them. Less latency, and the customer sees everything without the agent deciding what to show.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;qualify&lt;/strong&gt; fires when the query is genuinely ambiguous. "I need a gift" could match every category. The preprocessor asks what kind of gift, mentioning the actual categories available. But only when the ambiguity is real. "I need headphones" goes straight to search even on a store with 200 headphones, because the intent is clear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;search&lt;/strong&gt; is the default. The query goes to the full agent with hybrid search, reranking, and tool use. But the preprocessor passes along structured filters it already extracted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExtractedFilters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;     &lt;span class="c1"&gt;# Matched to real store categories
&lt;/span&gt;    &lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;price_min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;price_max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AttributeFilter&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# [{"name": "color", "value": "red"}]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Wireless Sony headphones under $200" becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"categories"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Headphones"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"brand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Sony"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"price_max"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"connectivity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wireless"&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The categories and brands are matched against real store data. The preprocessor won't extract "Nike" as a brand if the store doesn't carry Nike. The agent starts with filters grounded in real inventory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tradeoff: An Extra LLM Call
&lt;/h2&gt;

&lt;p&gt;Every product query now makes at least two LLM calls: the preprocessor, then the agent. The preprocessor uses a fast, cheap model (GPT-4o-mini or DeepSeek). It costs fractions of a cent and adds under a second of latency.&lt;/p&gt;

&lt;p&gt;I considered doing this without an LLM. Pattern matching on category names, fuzzy string matching, keyword overlap. But the mapping requires understanding that "phones" means Smartphones, "TVs" means Televisions, "something for the kitchen" means Kitchen Appliances. String comparison can't do this reliably.&lt;/p&gt;

&lt;p&gt;The structured output (&lt;code&gt;QueryAnalysis&lt;/code&gt; as a Pydantic model with &lt;code&gt;response_model&lt;/code&gt;) means the LLM returns typed data, not free text. Four possible actions, each with specific fields. No parsing ambiguity.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Still Goes Wrong
&lt;/h2&gt;

&lt;p&gt;The preprocessor depends on the LLM's judgment for category matching. "Winter coats" on a store with an "Outerwear" category requires the LLM to know that winter coats are a subset of outerwear. It usually gets this right. Not always.&lt;/p&gt;

&lt;p&gt;Conversation context can get lost. If the customer asked about headphones three messages ago and now says "what about the wireless ones?" the preprocessor needs the conversation summary to understand this is still about headphones. The summary is passed in, but summarization sometimes drops details.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;show_products&lt;/code&gt; path doesn't handle variation-heavy categories well. A category with 8 products that are all color variants of the same item shows 8 near-identical entries. I haven't solved this.&lt;/p&gt;

&lt;p&gt;And the preprocessor can be too aggressive with &lt;code&gt;no_match&lt;/code&gt;. If a store sells "Outdoor Gear" and the customer asks for "camping equipment," the LLM might not connect the two. The fallback is to route to &lt;code&gt;search&lt;/code&gt; when confidence is low, but some edge cases still produce unhelpful "we don't carry that" responses for products the store actually has under a different name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;Before the preprocessor: the agent searched for everything, hallucinated on misses, and had no sense of what the store carried.&lt;/p&gt;

&lt;p&gt;After: impossible queries get a direct answer immediately. Small categories get shown directly. Ambiguous queries get a focused question. The agent only runs when there's something worth searching for, and it starts with pre-extracted filters instead of raw text.&lt;/p&gt;

&lt;p&gt;The preprocessor itself is about 80 lines of logic plus another 80 for the Pydantic models. The prompt template is 50 lines. It added about 200 lines of code to the pipeline and cut hallucination on out-of-stock and wrong-category queries significantly.&lt;/p&gt;

&lt;p&gt;I wrote about the &lt;a href="https://dev.to/rosen_hristov/hybrid-search-for-e-commerce-when-keywords-alone-fail-1ojg"&gt;hybrid search pipeline&lt;/a&gt; and the &lt;a href="https://dev.to/rosen_hristov/why-i-split-one-langgraph-agent-into-four-running-in-parallel-2c65"&gt;parallel agent system&lt;/a&gt; in separate posts. The preprocessor sits before both, deciding what kind of handling each query needs. The code is part of &lt;a href="https://emporiqa.com" rel="noopener noreferrer"&gt;Emporiqa&lt;/a&gt;, a chat assistant for e-commerce stores. Free &lt;a href="https://emporiqa.com/platform/create-store/" rel="noopener noreferrer"&gt;sandbox&lt;/a&gt; if you want to try it.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>ecommerce</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why I Split One LangGraph Agent into Four Running in Parallel</title>
      <dc:creator>Rosen Hristov</dc:creator>
      <pubDate>Tue, 24 Feb 2026 13:31:15 +0000</pubDate>
      <link>https://forem.com/rosen_hristov/why-i-split-one-langgraph-agent-into-four-running-in-parallel-2c65</link>
      <guid>https://forem.com/rosen_hristov/why-i-split-one-langgraph-agent-into-four-running-in-parallel-2c65</guid>
      <description>&lt;p&gt;My first version was a single agent with 12 tools. Product search, page lookup, order tracking, general chitchat. One system prompt, one LLM call, one response.&lt;/p&gt;

&lt;p&gt;It worked fine for simple questions. "Show me running shoes" returned running shoes. "What's your return policy?" returned the return policy.&lt;/p&gt;

&lt;p&gt;Then someone asked: "Do you have the Nike Air Max in size 42, and what's your return policy for shoes?"&lt;/p&gt;

&lt;p&gt;The agent searched for Nike Air Max, found it, answered the sizing question, and completely forgot about the return policy. It had already used up its reasoning budget on the product search. The return policy part of the question just vanished.&lt;/p&gt;

&lt;p&gt;That is when I started breaking it into specialized agents.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;I built a chat assistant for e-commerce stores. It needs to handle product discovery (search, filter, compare), customer support (store policies, shipping info), and order tracking (status lookup via the store's API). These are fundamentally different tasks. A product search agent needs vector search tools and product database access. A customer support agent needs policy documents. An order tracking agent needs an external API client with HMAC signing.&lt;/p&gt;

&lt;p&gt;Here is the graph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;START -&amp;gt; summarize -&amp;gt; classify -&amp;gt; [parallel agents] -&amp;gt; merge -&amp;gt; END
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The classify node looks at the customer's message and decides which agents need to run. If someone asks "Show me running shoes and what's your return policy?", the classifier produces two assignments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;AgentAssignment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product_expert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sub_query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Show me running shoes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;AgentAssignment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_support&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sub_query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What is your return policy?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each agent gets its own sub-query. They run in parallel. Then a merge node combines the responses.&lt;/p&gt;

&lt;p&gt;Here's what the implementation actually looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Send API Is the Non-Obvious Part
&lt;/h2&gt;

&lt;p&gt;Most LangGraph tutorials show conditional edges: "if category is X, go to node A; if Y, go to node B." That is fine for single-agent routing. But when you need two agents to run in parallel on the same message, you need the Send API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langgraph.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Send&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_route_to_agents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;GraphState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;assignments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classification_assignments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;

    &lt;span class="n"&gt;agent_to_node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product_expert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product_expert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_support&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_support&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_tracking&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_tracking&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;general&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;general&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;sends&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;assignment&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;assignments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent_to_node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;sub_query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub_query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;sends&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;current_sub_query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sub_query&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sends&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sends&lt;/span&gt;

    &lt;span class="c1"&gt;# Fallback: if no assignments, send to general
&lt;/span&gt;    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="n"&gt;fallback_query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;general&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;current_sub_query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fallback_query&lt;/span&gt;&lt;span class="p"&gt;})]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;Send&lt;/code&gt; creates a branch with its own copy of the state, with &lt;code&gt;current_sub_query&lt;/code&gt; set to that agent's specific sub-question. The agents run in parallel and their results converge at the merge node.&lt;/p&gt;

&lt;p&gt;The part that tripped me up: when parallel branches write to the same state key, you need a reducer. Without one, the last branch to finish just overwrites the others.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_merge_agent_results&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;  &lt;span class="c1"&gt;# Empty dict resets (used between turns)
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GraphState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MessagesState&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;agent_results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_merge_agent_results&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Annotated&lt;/code&gt; type with the reducer function tells LangGraph how to merge &lt;code&gt;agent_results&lt;/code&gt; when multiple branches update it. Each agent writes its result under its own key (like &lt;code&gt;{"product_expert": {...}}&lt;/code&gt;), and the reducer merges them into one dict.&lt;/p&gt;

&lt;p&gt;One subtle detail: that empty-dict check at the top of the reducer. The classify node resets &lt;code&gt;agent_results&lt;/code&gt; to &lt;code&gt;{}&lt;/code&gt; at the start of each turn. Without this, results from the previous turn would bleed into the current one. I found this bug after a customer got a response that mixed product recommendations from their first question with policy information from their second.&lt;/p&gt;

&lt;h2&gt;
  
  
  State Management with contextvars
&lt;/h2&gt;

&lt;p&gt;LangGraph passes state between nodes as function arguments. But tools, services, and utility functions that run deep in the call stack also need access to request-scoped data. I needed three distinct scopes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ExecutionContext&lt;/strong&gt; (immutable per request): store ID, language, thread ID. Set once, never changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;GraphState&lt;/strong&gt; (mutable per turn): messages, conversation summary, classification results, agent outputs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ServiceCache&lt;/strong&gt; (request-scoped singletons): LLM instances, search services, database lookups that should only happen once per request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ServiceCache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nd"&gt;@classmethod&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;normal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;cache_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;model_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_get_cache&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cache_key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cache_key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LLMHelperService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;for_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;model_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cache_key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three live in &lt;code&gt;contextvars.ContextVar&lt;/code&gt;, which gives thread safety without passing objects through every function signature. A tool 5 layers deep in the call stack can call &lt;code&gt;ExecutionContext.get_store_id()&lt;/code&gt; and get the right store.&lt;/p&gt;

&lt;p&gt;The alternative is threading the store ID through every function call. I tried that first. It works until you have 15 tools across 3 agents, each calling 2-3 services. Then your function signatures turn into &lt;code&gt;def search_products(query, store_id, language, ...)&lt;/code&gt; everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Merging N Agent Responses
&lt;/h2&gt;

&lt;p&gt;Tutorials show merging two things. Production needs to handle 1, 2, 3, or 4 agents responding simultaneously.&lt;/p&gt;

&lt;p&gt;For a single agent, there is no merge. Just pass through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;first_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent_results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;agents&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
    &lt;span class="n"&gt;response_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;first_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For multiple agents, an LLM merges the responses into a single coherent reply. The merge prompt gets all agent responses formatted as numbered sections, plus the original query and the customer's language.&lt;/p&gt;

&lt;p&gt;One design decision I had to make: confidence aggregation. When three agents respond and one has low confidence, should that trigger a human handoff? My formula weights the minimum confidence at 70% and the average at 30%:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_get_confidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;confidences&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;agent_results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;confidences&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;confidences&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;confidences&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;confidences&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;confidences&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way, one confused agent can flag the whole response for review, but two confident agents can offset a slightly uncertain third. I tuned these weights manually over a few hundred test conversations.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Goes Wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Misclassification.&lt;/strong&gt; The classifier sometimes sends a product question to the general agent, or splits a single question into two agents when one would do. I use the "fast" LLM for classification (cheaper, faster) and the "normal" model for the agents themselves. The tradeoff is that the fast model occasionally gets the routing wrong. Structured output with Pydantic helps (the classifier returns a typed &lt;code&gt;list[AgentAssignment]&lt;/code&gt;, not free text), but it is not perfect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Feature availability at runtime.&lt;/strong&gt; Order tracking requires the store to have an API endpoint configured. If the classifier routes to order tracking but the store has not set one up, I remap to customer support at dispatch time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_tracking&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;order_tracking_available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_support&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means the customer support agent gets a question like "Where is my order #12345?" and has to explain that order tracking is not available, rather than just failing silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first-turn optimization.&lt;/strong&gt; Generating a conversation summary on the very first message is wasteful. There is nothing to summarize. So the summarize node checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_is_first_turn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous_summary&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;previous_summary&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On first turn, it just truncates the message to 200 characters as the "summary." The LLM call only happens from the second turn onward, when there is actual conversation history to compress.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;This architecture adds latency. Classify, dispatch, execute, merge: that is 3-4 LLM calls minimum for a multi-agent response, versus 1 for a single agent. For simple questions ("What are your store hours?"), the overhead of classification and merging is unnecessary. I have not found a clean way to short-circuit this without adding yet another classification step.&lt;/p&gt;

&lt;p&gt;The connection pool for the PostgreSQL checkpointer needs careful sizing. I run 7 Gunicorn workers with a pool of max 5 connections each. That is 35 potential connections just for checkpointing, on top of Django's own database connections. If your database has a low &lt;code&gt;max_connections&lt;/code&gt;, this will bite you.&lt;/p&gt;

&lt;p&gt;The confidence aggregation formula is hand-tuned. It works for my use case but I have no proof it generalizes. If you have a better approach for aggregating confidence across parallel agents, I would like to hear it.&lt;/p&gt;

&lt;p&gt;The product expert agent uses a &lt;a href="https://dev.to/rosen_hristov/hybrid-search-for-e-commerce-when-keywords-alone-fail-1ojg"&gt;hybrid search pipeline&lt;/a&gt; (vector + BM25 with cross-encoder reranking). I wrote more about how the agent architecture works in practice &lt;a href="https://emporiqa.com/blog/ai-agent-architecture-specialized-chatbot-ecommerce/" rel="noopener noreferrer"&gt;on the Emporiqa blog&lt;/a&gt;. You can see all four agents working on a real catalog with a free &lt;a href="https://emporiqa.com/platform/create-store/" rel="noopener noreferrer"&gt;sandbox&lt;/a&gt; at &lt;a href="https://emporiqa.com" rel="noopener noreferrer"&gt;Emporiqa&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>langchain</category>
      <category>database</category>
      <category>rag</category>
    </item>
    <item>
      <title>Hybrid Search for E-commerce: When Keywords Alone Fail</title>
      <dc:creator>Rosen Hristov</dc:creator>
      <pubDate>Tue, 24 Feb 2026 08:17:24 +0000</pubDate>
      <link>https://forem.com/rosen_hristov/hybrid-search-for-e-commerce-when-keywords-alone-fail-1ojg</link>
      <guid>https://forem.com/rosen_hristov/hybrid-search-for-e-commerce-when-keywords-alone-fail-1ojg</guid>
      <description>&lt;p&gt;I wrote about &lt;a href="https://dev.to/rosen_hristov/i-built-vector-only-search-first-heres-why-i-had-to-rewrite-it-4dh0"&gt;abandoning vector-only search&lt;/a&gt; after SKU lookups returned random products. That post covered the problem. This one covers the solution: running BM25 and vector search in parallel, merging results, and reranking with a cross-encoder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BM25&lt;/strong&gt; handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exact product names and SKUs&lt;/li&gt;
&lt;li&gt;Brand names ("Nike", "Bosch")&lt;/li&gt;
&lt;li&gt;Specific attributes ("size 38", "500ml", "red")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Vector search&lt;/strong&gt; handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Natural language descriptions ("something warm for winter")&lt;/li&gt;
&lt;li&gt;Intent-based queries ("gift for a coffee lover")&lt;/li&gt;
&lt;li&gt;Cross-language queries (customer asks in German, catalog is in English)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How the Merge Works
&lt;/h3&gt;

&lt;p&gt;Both searches return scored results. The trick is normalizing scores so they're comparable, then combining them with configurable weights.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hybrid_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Run both searches in parallel
&lt;/span&gt;    &lt;span class="n"&gt;bm25_results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bm25_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;vector_results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;vector_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Normalize scores to 0-1 range
&lt;/span&gt;    &lt;span class="n"&gt;bm25_scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bm25_results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;vector_scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vector_results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Merge with weights (tuned per use case)
&lt;/span&gt;    &lt;span class="n"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;bm25_scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;BM25_WEIGHT&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;vector_scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;VECTOR_WEIGHT&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;VECTOR_WEIGHT&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The weights need tuning per store. Stores with lots of SKU-based lookups benefit from higher BM25 weight. Stores where customers describe what they want (fashion, home goods) benefit from higher vector weight. I don't have a universal formula. Start at 50/50 and adjust based on your query logs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cross-Encoder Reranking
&lt;/h3&gt;

&lt;p&gt;After the merge, a cross-encoder reranker compares each candidate directly against the query.&lt;/p&gt;

&lt;p&gt;Unlike bi-encoders (which encode query and product separately), cross-encoders take the pair as input and output a relevance score. More expensive, but more accurate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CrossEncoder&lt;/span&gt;

&lt;span class="n"&gt;reranker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CrossEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cross-encoder/ms-marco-MiniLM-L-6-v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rerank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reranker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rerank_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rerank_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I run this on the top 20-30 candidates from hybrid search, not the full catalog. This keeps response times reasonable since cross-encoders are slow on large sets.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Note: the code examples above are simplified for clarity. Production code needs error handling, async execution, and score caching.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-Language Search
&lt;/h2&gt;

&lt;p&gt;One side effect of using &lt;code&gt;intfloat/multilingual-e5-large&lt;/code&gt;: it maps 100+ languages into the same vector space. A query in French against an English catalog returns correct results because the embedding model treats meaning, not language, as the proximity metric. No translation API needed. If you sell across borders, the multilingual embedding model does the work for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Doesn't Do
&lt;/h2&gt;

&lt;p&gt;Limitations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Image search.&lt;/strong&gt; Customers can't upload a photo and find matching products. This is a different problem requiring CLIP or similar models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personalization.&lt;/strong&gt; The search doesn't learn from individual user behavior. It treats every query independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typo correction.&lt;/strong&gt; Heavy typos can throw off both BM25 and vector search. I handle this with query preprocessing, but it's not perfect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time inventory.&lt;/strong&gt; Search returns products that exist in the catalog. Stock availability is a separate check.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I've Seen in Practice
&lt;/h2&gt;

&lt;p&gt;I don't have clean A/B test data to share yet. What I can say from manually testing across several store catalogs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keyword-only search fails on most natural language queries. If the customer doesn't use the exact product name, they get nothing.&lt;/li&gt;
&lt;li&gt;Vector-only search handles descriptions well but returns wrong results for SKU lookups and specific attributes (color, size).&lt;/li&gt;
&lt;li&gt;Hybrid search with reranking handles both query types. SKU searches still work. Descriptive queries return relevant products.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I won't put a percentage on it until I have proper metrics. If you're building this, set up evaluation before you ship.&lt;/p&gt;

&lt;p&gt;The data sync pipeline that feeds this search engine handles 60,000+ product catalogs with batch embeddings and hash-based skip logic. I wrote about that in &lt;a href="https://dev.to/rosen_hristov/syncing-60000-products-without-breaking-everything-278c"&gt;Syncing 60,000 Products Without Breaking Everything&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I built this as part of &lt;a href="https://emporiqa.com" rel="noopener noreferrer"&gt;Emporiqa&lt;/a&gt;. There's a &lt;a href="https://emporiqa.com/blog/ai-product-search-ecommerce-keywords-conversations/" rel="noopener noreferrer"&gt;deeper dive on how product search works end-to-end&lt;/a&gt; on the Emporiqa blog. You can test hybrid search on your own catalog with a free &lt;a href="https://emporiqa.com/platform/create-store/" rel="noopener noreferrer"&gt;sandbox&lt;/a&gt; — 100 products, no credit card.&lt;/p&gt;

</description>
      <category>python</category>
      <category>machinelearning</category>
      <category>database</category>
      <category>rag</category>
    </item>
    <item>
      <title>Syncing 60,000 Products Without Breaking Everything</title>
      <dc:creator>Rosen Hristov</dc:creator>
      <pubDate>Fri, 20 Feb 2026 13:48:23 +0000</pubDate>
      <link>https://forem.com/rosen_hristov/syncing-60000-products-without-breaking-everything-278c</link>
      <guid>https://forem.com/rosen_hristov/syncing-60000-products-without-breaking-everything-278c</guid>
      <description>&lt;p&gt;A dental supply store with 60,000 products wants to sync their catalog to my search engine. Every product needs a vector embedding. Here's what I learned about not melting the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Webhooks, Not Pull
&lt;/h2&gt;

&lt;p&gt;The first architectural decision was whether my system should pull data from stores or let stores push data to it.&lt;/p&gt;

&lt;p&gt;Pull-based sync sounds simpler: hit the store's API on a schedule, diff the results, update what changed. In practice it falls apart. You need to poll every store on some interval, deal with pagination across different APIs, handle rate limits on the store's side, and figure out what changed since last time. If you support multiple platforms (Drupal, WooCommerce, Sylius, custom), every platform has a different API shape.&lt;/p&gt;

&lt;p&gt;I went with webhook-only. Stores push events to a single endpoint: &lt;code&gt;POST /webhooks/sync/{store_id}/&lt;/code&gt;. The payload is a list of typed events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"events"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"product.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"identification_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SKU-123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"product.updated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"identification_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SKU-456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine event types total: &lt;code&gt;product.created&lt;/code&gt;, &lt;code&gt;product.updated&lt;/code&gt;, &lt;code&gt;product.deleted&lt;/code&gt;, and the same for pages, plus &lt;code&gt;sync.start&lt;/code&gt;, &lt;code&gt;sync.complete&lt;/code&gt;, and &lt;code&gt;order.completed&lt;/code&gt;. Every event goes through a Pydantic schema before anything happens. Invalid payload? 400 response before it ever hits a queue.&lt;/p&gt;

&lt;p&gt;The tradeoff is real: now every store integration has to implement webhook sending. But I ship official modules for Drupal and WooCommerce that handle it, and for everything else there's a documented webhook API. The upside is the system never needs to know how any store's API works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Processing Pipeline
&lt;/h2&gt;

&lt;p&gt;A webhook request hits the endpoint and goes through five checks before anything gets queued:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rate limit&lt;/strong&gt; (120 requests per 60 seconds per store, sliding window in Redis)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store lookup&lt;/strong&gt; (does this &lt;code&gt;store_id&lt;/code&gt; exist?)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscription check&lt;/strong&gt; (is the subscription active?)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HMAC-SHA256 signature&lt;/strong&gt; (every store has an encrypted webhook secret, auto-generated on creation)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema validation&lt;/strong&gt; (every event validated against its Pydantic model)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If all five pass, the events go to Celery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;process_webhook_events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;events_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint returns 202 immediately. The store doesn't wait for embeddings or database writes. The Celery task picks it up, and &lt;code&gt;WebhookProcessor.process_events()&lt;/code&gt; handles the actual work.&lt;/p&gt;

&lt;p&gt;One detail that cost me a bug: sync session item registration happens &lt;em&gt;synchronously&lt;/em&gt; at webhook receipt time, before Celery picks up the events. I'll explain why in the reconciliation section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hash-Based Skip Logic
&lt;/h2&gt;

&lt;p&gt;Generating an embedding for a product is the expensive part. The embedding model runs in a separate inference service (a FastAPI app with a sentence transformer), and each call takes real time. For 60,000 products, you can't regenerate every embedding on every sync.&lt;/p&gt;

&lt;p&gt;Each product has a content hash: a SHA-256 of its embedding prompt (name, SKU, category, brand, description, attributes). When a &lt;code&gt;product.updated&lt;/code&gt; event comes in, the processor looks up the existing product in Qdrant and compares hashes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;qdrant_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_product_by_identification_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identification_number&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_product_has_non_hash_changes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;# nothing changed, skip entirely
&lt;/span&gt;    &lt;span class="c1"&gt;# only price/stock/images changed, reuse existing embedding
&lt;/span&gt;    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three outcomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hash matches, no other changes&lt;/strong&gt;: skip entirely. No embedding, no write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hash matches, but price/stock/images changed&lt;/strong&gt;: reuse the existing embedding, update only the mutable fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hash differs&lt;/strong&gt;: generate new embedding, write everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In a typical "full resync" of 60,000 products where maybe 200 actually changed, this skips embedding generation for 59,800 of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch Processing
&lt;/h2&gt;

&lt;p&gt;Product create and update events get special treatment. Instead of processing one at a time, they're collected and run through a three-phase batch pipeline:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 1 (Collect):&lt;/strong&gt; Build product objects, check subscription limits, hash-check against Qdrant. Products that need new embeddings go in one list; products that can reuse existing embeddings go in another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 2 (Batch embed):&lt;/strong&gt; One call to the inference service's &lt;code&gt;/embed/batch&lt;/code&gt; endpoint per batch of 50 products. Instead of 200 individual HTTP calls, you get 4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 3 (Batch upsert):&lt;/strong&gt; One Qdrant upsert for all products.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;texts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_embedding_prompt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;products_needing_embedding&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embedding_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_embeddings_batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;products_needing_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The batch size of 50 for the inference service is a tuned number. Higher and the inference service starts timing out. Lower and the HTTP overhead adds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full Sync Reconciliation
&lt;/h2&gt;

&lt;p&gt;Incremental webhooks handle the common case: a store updates a product, sends &lt;code&gt;product.updated&lt;/code&gt;, done. But what about products that got deleted on the store side and no one sent a &lt;code&gt;product.deleted&lt;/code&gt;? What about data drift after weeks of intermittent sync failures?&lt;/p&gt;

&lt;p&gt;That's what full sync reconciliation solves. The protocol is three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Store sends &lt;code&gt;sync.start&lt;/code&gt; with a &lt;code&gt;session_id&lt;/code&gt;, &lt;code&gt;entity&lt;/code&gt; ("products" or "pages"), and &lt;code&gt;channel&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Store sends every product as &lt;code&gt;product.created&lt;/code&gt; or &lt;code&gt;product.updated&lt;/code&gt;, each with a &lt;code&gt;sync_session_id&lt;/code&gt; field — all translations consolidated in one event&lt;/li&gt;
&lt;li&gt;Store sends &lt;code&gt;sync.complete&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The system tracks which items it saw during the session. When &lt;code&gt;sync.complete&lt;/code&gt; arrives, anything in Qdrant that wasn't seen gets soft-deleted.&lt;/p&gt;

&lt;p&gt;The session state lives in Redis with a 24-hour TTL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Key structure
&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sync_session:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# stores the session_id
&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sync_items:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# stores a set of seen IDs
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When each event arrives at the webhook endpoint, the item's &lt;code&gt;identification_number&lt;/code&gt; gets added to the seen-items set &lt;em&gt;before&lt;/em&gt; the event is queued to Celery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_register_sync_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sync_session_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sync_session_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;sync_session_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;SyncSessionService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sync_session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;identification_number&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was a bug fix. Originally I registered items during Celery processing. But Celery tasks run asynchronously, and if the store sent &lt;code&gt;sync.complete&lt;/code&gt; in a separate request that arrived while product events were still queued, the seen-items set was incomplete. Items got deleted that shouldn't have been. Moving registration to the synchronous webhook handler fixed the race condition.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;sync.complete&lt;/code&gt;, the processor gets all product IDs from Qdrant, diffs them against the seen set, and soft-deletes the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;all_products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;qdrant_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_all_product_ids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;identification_number&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;all_products&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;identification_number&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# soft-delete: set deleted=True in Qdrant
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What Broke Along the Way
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The sync registration race condition&lt;/strong&gt; was the worst one. A store would sync 60,000 products across hundreds of webhook requests, then send &lt;code&gt;sync.complete&lt;/code&gt;. But Celery hadn't finished processing all the product events yet, so the seen-items set was missing thousands of entries. The cleanup step would delete products that were still being processed. I noticed because a store reported half their catalog vanishing after a sync. The fix was registering items at the webhook endpoint, not in the Celery worker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subscription limit tracking during batch processing&lt;/strong&gt; had a similar counting bug. The &lt;code&gt;_check_product_limit&lt;/code&gt; method queries Qdrant for the current count, but during batch processing, new products haven't been upserted yet. If a store on a 2,000-product plan sent 100 new products in one batch, the limit check saw the same count for all 100 and let them all through. I added an in-memory counter (&lt;code&gt;new_product_counts&lt;/code&gt; dict, keyed by channel) that tracks how many new products have been approved within the current batch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inference service timeouts&lt;/strong&gt; with large batches. My first implementation sent all products needing embeddings in one HTTP call. With 500 products, the inference service would take 30+ seconds and the HTTP client would time out. Chunking into batches of 50 with a separate &lt;code&gt;INFERENCE_SERVICE_BATCH_TIMEOUT&lt;/code&gt; setting fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Still Not Great
&lt;/h2&gt;

&lt;p&gt;The seen-items set for sync sessions is stored as a Python set in Redis (via Django's cache framework). For a 60,000-product catalog, that's 60,000 strings in memory. A Redis set with &lt;code&gt;SADD&lt;/code&gt;/&lt;code&gt;SMEMBERS&lt;/code&gt; would be more memory-efficient. Haven't hit a wall yet, but it's on the list.&lt;/p&gt;

&lt;p&gt;The reconciliation cleanup iterates over every product ID and does individual lookups and updates. For very large catalogs with thousands of deletions, this is slow. Qdrant's filtering doesn't support "not in this set of IDs" directly, so there's no bulk shortcut.&lt;/p&gt;

&lt;p&gt;There's no retry logic at the event level. The Celery task retries up to 3 times on failure, but that retries the entire batch — re-doing hash checks and Qdrant lookups for products that already succeeded. Single-product failures get logged and skipped, which is correct. Full inference service outages are the painful case.&lt;/p&gt;

&lt;p&gt;The Drupal side of this pipeline — entity hooks, queue workers, alter hooks for customization — is an &lt;a href="https://www.drupal.org/project/emporiqa" rel="noopener noreferrer"&gt;official module on drupal.org&lt;/a&gt;. I wrote about how it handles Drupal's schema flexibility in &lt;a href="https://dev.to/rosen_hristov/building-a-chat-assistant-module-for-drupal-commerce-6e6"&gt;Building a Chat Assistant Module for Drupal Commerce&lt;/a&gt;. The &lt;a href="https://emporiqa.com/docs/webhook-setup/" rel="noopener noreferrer"&gt;webhook documentation&lt;/a&gt; covers the full event schema and authentication setup for any platform. You can see the full pipeline working with a free &lt;a href="https://emporiqa.com/platform/create-store/" rel="noopener noreferrer"&gt;sandbox&lt;/a&gt; — syncs up to 100 products in about 2 minutes.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>database</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Building a Chat Assistant Module for Drupal Commerce</title>
      <dc:creator>Rosen Hristov</dc:creator>
      <pubDate>Fri, 20 Feb 2026 13:46:14 +0000</pubDate>
      <link>https://forem.com/rosen_hristov/building-a-chat-assistant-module-for-drupal-commerce-6e6</link>
      <guid>https://forem.com/rosen_hristov/building-a-chat-assistant-module-for-drupal-commerce-6e6</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I spent 12 years building Drupal sites before I started working on &lt;a href="https://emporiqa.com" rel="noopener noreferrer"&gt;Emporiqa&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;I went with webhooks. The module pushes data out. The external service never queries Drupal directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Webhooks Instead of an API Client
&lt;/h2&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The receiving side gets a standardized structure regardless of how the Drupal store is configured internally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"product.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"identification_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"product-42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HB-GTX-42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"channels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hiking Boot GTX"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"de"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Wanderstiefel GTX"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"descriptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Waterproof hiking boot with..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"de"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Wasserdichter Wanderstiefel mit..."&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"links"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://store.com/hiking-boot-gtx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"de"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://store.com/de/wanderstiefel-gtx"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"categories"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Footwear"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"de"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Schuhe"&lt;/span&gt;&lt;span class="p"&gt;]}},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"brands"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Salomon"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"prices"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"current_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;159.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"regular_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;189.99&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"availability_statuses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"available"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"material"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Gore-Tex"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"weight"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"450g"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"de"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"Material"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Gore-Tex"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Gewicht"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"450g"&lt;/span&gt;&lt;span class="p"&gt;}}},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"images"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://store.com/boot-1.jpg"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Entity Hooks, Not cron
&lt;/h2&gt;

&lt;p&gt;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+):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In EmporiqaHooks:&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;commerceProductInsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ProductInterface&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;queueProductEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'created'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;commerceProductUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ProductInterface&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;queueProductEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'updated'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;commerceProductDelete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ProductInterface&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;queueProductEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deleted'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// ... same for variations and nodes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is standard Drupal: entity hooks, &lt;code&gt;QueueInterface&lt;/code&gt;, &lt;code&gt;QueueWorkerBase&lt;/code&gt;. Nothing unusual. If you have built a custom sync module before, this pattern is familiar.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Display Modes for Field Mapping
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;/admin/config/services/emporiqa&lt;/code&gt; lets you adjust which fields map to what.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;For pages and advanced use cases, the module also supports a custom &lt;code&gt;emporiqa&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alter Hooks: Eight Extension Points
&lt;/h2&gt;

&lt;p&gt;Drupal developers expect hooks. The module provides eight:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;hook_emporiqa_entity_sync_alter&lt;/code&gt;&lt;/strong&gt; controls whether an entity syncs at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;mymodule_emporiqa_entity_sync_alter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$sync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;EntityInterface&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$entity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$operation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Skip draft products&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'moderation_state'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$sync&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;FALSE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Skip products in a specific category&lt;/span&gt;
  &lt;span class="nv"&gt;$category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'field_category'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$category&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$category&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'Internal Only'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$sync&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;FALSE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;hook_emporiqa_data_alter&lt;/code&gt;&lt;/strong&gt; modifies the payload before it is sent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;mymodule_emporiqa_data_alter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;$entity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'entity'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="c1"&gt;// Add vehicle compatibility from a custom field.&lt;/span&gt;
  &lt;span class="c1"&gt;// Attributes use consolidated format: {channel: {lang: value}}.&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'field_vehicle_compatibility'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$vehicles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'field_vehicle_compatibility'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nv"&gt;$vehicles&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'attributes'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$channel&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$languages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$languages&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$lang&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$attrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$attrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'compatible_vehicles'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$vehicles&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;hook_emporiqa_channels_alter&lt;/code&gt;&lt;/strong&gt; assigns products to sales channels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;mymodule_emporiqa_channels_alter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$channels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;ProductInterface&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Assign products to channels based on their Commerce stores.&lt;/span&gt;
  &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getStores&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$store&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'field_channel'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nv"&gt;$channels&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$store&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'field_channel'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;hook_emporiqa_tier_prices_alter&lt;/code&gt;&lt;/strong&gt; provides volume discount tiers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;mymodule_emporiqa_tier_prices_alter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$tier_prices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;$variation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'variation'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$variation&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'field_tier_prices'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$variation&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'field_tier_prices'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nv"&gt;$tier_prices&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'min_quantity'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;min_qty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'currency'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'currency'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;hook_emporiqa_price_entry_alter&lt;/code&gt;&lt;/strong&gt; modifies individual price entries (e.g., add tax-inclusive/exclusive prices):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;mymodule_emporiqa_price_entry_alter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$price_entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Add tax-inclusive price alongside the default tax-exclusive price.&lt;/span&gt;
  &lt;span class="nv"&gt;$price_entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'price_incl_tax'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$price_entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'current_price'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.21&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;hook_emporiqa_cart_alter&lt;/code&gt;&lt;/strong&gt; intercepts cart operations before they execute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;mymodule_emporiqa_cart_alter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Enforce maximum quantity per item&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'operation'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'add'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'data'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'quantity'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'cancel'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'cancel_message'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Maximum 10 items per product.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;hook_emporiqa_checkout_route_alter&lt;/code&gt;&lt;/strong&gt; overrides the checkout route used for cart checkout URL generation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;mymodule_emporiqa_checkout_route_alter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$route_name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Use a custom checkout route instead of the default.&lt;/span&gt;
  &lt;span class="nv"&gt;$route_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'my_custom_checkout.form'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;hook_emporiqa_order_tracking_alter&lt;/code&gt;&lt;/strong&gt; provides custom order lookup for non-Commerce order systems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;mymodule_emporiqa_order_tracking_alter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?array&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$order_identifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Look up from external ERP&lt;/span&gt;
  &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;my_erp_get_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order_identifier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s1"&gt;'order_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s1"&gt;'placed_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s1"&gt;'items'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All eight hooks live in your custom module, not in the Emporiqa module. When the module updates via Composer, your customizations stay untouched.&lt;/p&gt;

&lt;p&gt;I used the &lt;code&gt;data_alter&lt;/code&gt; 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 &lt;code&gt;attributes&lt;/code&gt; field, so the chat assistant could answer "Does this fit a 2019 Ford Focus?" without needing to understand Drupal's entity reference system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full Sync and Reconciliation
&lt;/h2&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;Drush commands handle bulk sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;drush emporiqa:sync-products    &lt;span class="c"&gt;# Sync all products (alias: em:sp)&lt;/span&gt;
drush emporiqa:sync-pages       &lt;span class="c"&gt;# Sync pages (alias: em:spg)&lt;/span&gt;
drush emporiqa:sync-all         &lt;span class="c"&gt;# Both at once (alias: em:sa)&lt;/span&gt;
drush emporiqa:test-connection  &lt;span class="c"&gt;# Verify webhook connectivity (alias: em:tc)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The sync uses a session-based reconciliation protocol: the Drush command sends &lt;code&gt;sync.start&lt;/code&gt;, then every product with a session ID attached, then &lt;code&gt;sync.complete&lt;/code&gt;. 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.&lt;/p&gt;

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

&lt;p&gt;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 &lt;a href="https://dev.to/rosen_hristov/syncing-60000-products-without-breaking-everything-278c"&gt;Syncing 60,000 Products Without Breaking Everything&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cart Operations and Conversion Tracking
&lt;/h2&gt;

&lt;p&gt;Two features that go beyond sync:&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  Order Tracking
&lt;/h2&gt;

&lt;p&gt;The module provides a built-in endpoint at &lt;code&gt;/emporiqa/api/order/tracking&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;For non-Commerce order systems (external ERP, custom tables), implement &lt;code&gt;hook_emporiqa_order_tracking_alter()&lt;/code&gt; to provide your own lookup logic.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Multilingual
&lt;/h2&gt;

&lt;p&gt;The module works with Drupal's translation system. Products sync with all translations in a single webhook event — names, descriptions, categories, and attributes are nested by channel and language code. If you have English, German, and French translations, they are all bundled into one payload per product. The chat widget detects the customer's language from the embed tag and returns responses in that language.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Doesn't Cover
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Variation complexity.&lt;/strong&gt; 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 &lt;code&gt;data_alter&lt;/code&gt; hook for highly customized setups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom pricing.&lt;/strong&gt; The webhook payload sends &lt;code&gt;prices&lt;/code&gt; as a channel-keyed object (e.g., &lt;code&gt;{"": [...]}&lt;/code&gt;) where each channel contains an array of price entries per currency (each has &lt;code&gt;current_price&lt;/code&gt;, optional &lt;code&gt;regular_price&lt;/code&gt;, 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom pricing.&lt;/strong&gt; The webhook payload sends &lt;code&gt;prices&lt;/code&gt; as a channel-keyed object (e.g., &lt;code&gt;{"1": [...]}&lt;/code&gt;) where each channel contains an array of price entries per currency (each has &lt;code&gt;current_price&lt;/code&gt;, optional &lt;code&gt;regular_price&lt;/code&gt;, 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private products.&lt;/strong&gt; The module syncs all published products by default. If some products should only be visible to specific user roles, you need to use the &lt;code&gt;entity_sync_alter&lt;/code&gt; hook to filter them out.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Module Structure
&lt;/h2&gt;

&lt;p&gt;For developers who want to look at the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The module is an &lt;a href="https://www.drupal.org/project/emporiqa" rel="noopener noreferrer"&gt;official module on drupal.org&lt;/a&gt;. Works with Drupal 10.3+ and 11.x, Commerce 2.40+ and 3.x, PHP 8.1+. The &lt;a href="https://emporiqa.com/docs/drupal/" rel="noopener noreferrer"&gt;setup and installation guide&lt;/a&gt; covers everything from Composer install to field mapping. I also wrote about &lt;a href="https://emporiqa.com/blog/drupal-commerce-ai-chatbot-developer-guide/" rel="noopener noreferrer"&gt;the full integration story&lt;/a&gt; on the Emporiqa blog. If you want to try it on a real store, the &lt;a href="https://emporiqa.com/platform/create-store/" rel="noopener noreferrer"&gt;sandbox&lt;/a&gt; is free — 100 products, no credit card.&lt;/p&gt;

</description>
      <category>php</category>
      <category>drupal</category>
      <category>webdev</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>I Built Vector-Only Search First. Here's Why I Had to Rewrite It.</title>
      <dc:creator>Rosen Hristov</dc:creator>
      <pubDate>Fri, 20 Feb 2026 06:32:07 +0000</pubDate>
      <link>https://forem.com/rosen_hristov/i-built-vector-only-search-first-heres-why-i-had-to-rewrite-it-4dh0</link>
      <guid>https://forem.com/rosen_hristov/i-built-vector-only-search-first-heres-why-i-had-to-rewrite-it-4dh0</guid>
      <description>&lt;p&gt;I spent three weeks building a pure vector search for an e-commerce product catalog. Embedded everything with &lt;code&gt;multilingual-e5-large&lt;/code&gt;, loaded it into Qdrant, and ran my first test queries.&lt;/p&gt;

&lt;p&gt;"Gift for someone who likes cooking" returned kitchen knives and spice sets. Great.&lt;/p&gt;

&lt;p&gt;"Nike Air Max 90 black" returned Adidas running shoes.&lt;/p&gt;

&lt;p&gt;"XJ-4520" (an actual product SKU) returned a random kitchen appliance.&lt;/p&gt;

&lt;p&gt;I had a semantic search engine that understood meaning but couldn't handle the simplest exact-match lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Vector Search Is Good At
&lt;/h2&gt;

&lt;p&gt;Embeddings map text into a high-dimensional space where similar meanings cluster together. When a customer types "gift for someone who likes cooking," the embedding lands near kitchen knives, cookbooks, and spice sets, even though none of those products contain the word "gift."&lt;/p&gt;

&lt;p&gt;For descriptive queries, it works well. I tested it across five languages and the model (&lt;code&gt;intfloat/multilingual-e5-large&lt;/code&gt;) mapped them all into the same space. A query in Bulgarian against an English catalog returned correct results. No translation layer, no language detection. Just math.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where It Fell Apart
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SKUs and model numbers.&lt;/strong&gt; "XJ-4520" is a meaningless string to an embedding model. It gets projected somewhere in vector space, and the nearest neighbors are whatever other meaningless strings happen to be nearby. In my tests, SKU lookups almost never returned the right product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Brand + attribute combos.&lt;/strong&gt; "Nike Air Max 90 black size 42" should return exactly one product. Vector search returned Nike products, but also Adidas and Puma, because they're all semantically "athletic shoes." The exact match was sometimes on page two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Numeric filters.&lt;/strong&gt; "Under $50" or "500ml bottle" — embeddings don't understand numbers as constraints. They understand that 500ml is semantically related to "bottle" and "liquid," but they won't filter by numeric value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short, specific queries.&lt;/strong&gt; When a customer types just "Bosch" with nothing else, vector search returned random power tools. BM25 would return all Bosch products ranked by relevance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Add BM25 Back
&lt;/h2&gt;

&lt;p&gt;I ended up running BM25 and vector search in parallel against the same catalog, then merging results with normalized scores.&lt;/p&gt;

&lt;p&gt;BM25 handles exact matches: SKUs, brand names, specific attributes. Vector search handles everything else: descriptive queries, intent-based searches, cross-language.&lt;/p&gt;

&lt;p&gt;The merge is the interesting part. Both engines return scored results, but the scores aren't comparable (BM25 scores can be 0-25+, vector similarity is 0-1). You have to normalize both to the same range before combining.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize_scores&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;min_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;max_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;max_score&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;min_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;min_score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_score&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;min_score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After normalization, I combine with configurable weights. The right ratio depends on the store. A parts supplier where customers search by part number needs heavier BM25. A fashion store where customers describe what they want needs heavier vector.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Don't start with vector-only.&lt;/strong&gt; Every tutorial I read at the time said "just embed your documents and search." None of them mentioned that exact-match queries break completely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BM25 is underrated.&lt;/strong&gt; It's a 30-year-old algorithm and still the best thing we have for exact token matching. Qdrant added built-in BM25 support, which means you can run both in the same database without maintaining Elasticsearch on the side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test with real queries, not demo queries.&lt;/strong&gt; My initial tests all used descriptive sentences like "gift for a coffee lover." Those are the queries vector search is designed for. The moment I tested with what actual customers type (brand names, SKUs, "red shoes"), the problems showed up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-encoder reranking cleans up the merge.&lt;/strong&gt; After combining BM25 and vector results, I run a cross-encoder (&lt;code&gt;ms-marco-MiniLM-L-6-v2&lt;/code&gt;) on the top candidates. It compares each result directly against the query and re-sorts. This catches cases where the merge ranked something incorrectly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Cases in the Merge
&lt;/h2&gt;

&lt;p&gt;Tutorials stop at "combine the scores." In production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What if BM25 returns 50 results and vector returns 3? The merge skews heavily toward BM25.&lt;/li&gt;
&lt;li&gt;What if the query is a single word? Vector search works poorly on single tokens.&lt;/li&gt;
&lt;li&gt;What about queries that are half descriptive, half specific? "Red Nike something for running" needs both engines equally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I handle some of these with query analysis before search. If the query looks like a SKU (alphanumeric, no spaces), I skip vector search entirely. If it's a long descriptive sentence, I weight vector higher. But it's not clean. There's no universal solution. You tune it per store and keep adjusting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I haven't built image search. A customer can't upload a photo and find matching products.&lt;/li&gt;
&lt;li&gt;Typo handling is basic. Heavy misspellings confuse both BM25 and vector search.&lt;/li&gt;
&lt;li&gt;No personalization. Every query is independent — the system doesn't learn from a customer's browsing history.&lt;/li&gt;
&lt;li&gt;Score caching adds complexity. Embeddings are expensive to compute per request, so I cache them, but cache invalidation on product updates is its own problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want the implementation details — cross-encoder reranking, score normalization, per-store weight tuning — I wrote a follow-up: &lt;a href="https://dev.to/rosen_hristov/hybrid-search-for-e-commerce-when-keywords-alone-fail-1ojg"&gt;Hybrid Search for E-commerce: When Keywords Alone Fail&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I built this as part of &lt;a href="https://emporiqa.com" rel="noopener noreferrer"&gt;Emporiqa&lt;/a&gt;, a chat assistant for e-commerce stores. The search runs on Qdrant (vector + BM25 in a single database), &lt;code&gt;intfloat/multilingual-e5-large&lt;/code&gt; for embeddings, and &lt;code&gt;ms-marco-MiniLM-L-6-v2&lt;/code&gt; for reranking. I wrote more about &lt;a href="https://emporiqa.com/blog/ai-product-search-ecommerce-keywords-conversations/" rel="noopener noreferrer"&gt;how the search approach evolved and what it looks like in production&lt;/a&gt;. You can test it on your own catalog with a free &lt;a href="https://emporiqa.com/platform/create-store/" rel="noopener noreferrer"&gt;sandbox&lt;/a&gt; — 100 products, no credit card.&lt;/p&gt;

</description>
      <category>python</category>
      <category>machinelearning</category>
      <category>database</category>
      <category>rag</category>
    </item>
  </channel>
</rss>
