Skip to content

Developer notes

Overview · Installation · Configuration · Usage · FAQ

Builder::run() reads basket heads (orders with parent_order_id = 0: single orders and Multi-Vendor parent orders) in chunks of 500 order ids, takes the products of the head and its vendor orders whose status is in the selected statuses, skips baskets with more distinct products than the limit, and adds for every ordered product pair (A, B):

  • orders: number of baskets with A and B (support),
  • score: the sum of the basket weights 0.5 ^ (age in days / half-life) (1 when the half-life is 0),

and per product the number of baskets that contain it. Both go into staging tables with multi-row upserts. When all chunks are read, one transaction replaces the live pairs with the pairs that pass the minimum support and confidence (orders(A,B) * 100 / orders(A)). A run stops after its time budget (25 s for cron URL / admin fallback) and the next run continues from the saved cursor; a lock prevents parallel runs. Run marks and the cursor are stored with StorageData (files instead of ?:storage_data while the full page cache is on).

Candidates for a product: pins (in order) → pairs by score → category fallback (main category, by ?:product_popularity.total), minus exclusions, excluded categories and, in Multi-Vendor, other vendors. They are loaded with fn_get_products(['pid' => …]) plus fn_gather_additional_products_data() (storefront visibility, user groups, prices with catalog promotions) and filtered by Recommender::isPurchasable().

Table Contents
?:kv_fbt_pairs company_id (Ultimate storefront, otherwise 0), product_id, related_id, orders, score, confidence
?:kv_fbt_pairs_tmp, ?:kv_fbt_counts_tmp Staging of a running build
?:kv_fbt_manual product_id, related_id, type (P pin, X exclude, H hide), position
?:kv_fbt_stats Daily counters per day, storefront, product and placement (P product block, C cart block): impressions, clicks, items
?:kv_fbt_orders Attributed order lines: order_id (real vendor order in Multi-Vendor), product_id, source_id, amount, subtotal, timestamp
Hook Use
pre_add_to_cart Requests from our blocks carry kv_fbt_source: lines whose product is the source or one of its candidates get extra.kv_fbt = source; the click is counted. A kv_fbt marker sent from outside is removed; cart updates ($update) keep existing markers.
place_order Stores the order lines with the marker (?:kv_fbt_orders).
delete_order, delete_product_post Clean-up.
update_product_post Saves the Bought together tab (product_data[kv_fbt]), vendors limited to their own products.
before_dispatch Admin fallback rebuild at shutdown when due.
Template products:product_tabs (pre) The product block.
Template checkout:cart_content_bottom_left_buttons (post) The cart block (moved below the cart buttons by the script).
Promotion schema Bonus kv_fbt_discount (Discount::apply(), using the core fn_promotion_apply_discount()). The core skips unknown bonuses, so a disabled add-on leaves such promotions harmless.
Dispatch
kv_frequently_bought_together.track (storefront, POST) Impression beacon (navigator.sendBeacon); no CSRF token needed (schemas/security/csrf_validation.post.php), counts only active products. GET answers 404.
kv_frequently_bought_together.cron?key=… (storefront, GET) Rebuild by URL with the secret key; JSON result.
php admin.php --dispatch=kv_frequently_bought_together.cron Console rebuild without a time limit.
kv_frequently_bought_together.dashboard / .rebuild (admin) Analytics; Rebuild now (POST). Permissions: view reports / manage promotions; vendors are denied.

The product block depends only on the product, settings, pairs, pins and promotions, so cached pages are the same for every visitor (prices follow the core rules of the product page). The core caches the main block of products.view; schemas/block_manager/blocks.post.php adds update handlers for our tables, promotions and settings and makes the license state part of the cache key. No session access and no writes happen on storefront GET requests; impressions are POSTed by a beacon after the page was shown. The cart page is never cached.

Tygh\Addons\KvFrequentlyBoughtTogether\License\Client (generated) verifies the license. Gate points: Config::isActive() for the product and cart block data, the impression beacon, marking added products and rebuilds (cron, URL, admin). Not gated: the promotion bonus and attribution of lines already marked, so a started cart keeps its price.

Block Manager block for free placement, “customers who viewed this also viewed”, per-vendor analytics, quantity selection inside the block, recommendations for products with required text/file options.

Path Purpose
app/addons/kv_frequently_bought_together/addon.xml Settings, tables, install/uninstall functions
init.php, func.php Hooks, install (cron key, bonus label, first build), cron helpers, fn_kv_frequently_bought_together_is_licensed()
Tygh/Addons/KvFrequentlyBoughtTogether/Builder.php Batched rebuild of the pairs
Recommender.php Candidates, product and cart block data, purchasability
Manual.php Pins, exclusions, hide flag
Tracker.php, Stats.php Impressions, clicks, attribution; analytics queries
Discount.php Promotion bonus and the savings line
Config.php, StorageData.php Settings; run marks safe for the full page cache
License/, controllers/backend/kv_frequently_bought_together_license.php License check (generated)
Upgrade/Synchronizer.php Self-healing upgrades (generated)
controllers/frontend/, controllers/backend/ Product/cart data, beacon, cron, analytics, product tab
schemas/ Menu, permissions, promotion bonus, block cache, CSRF exception, setting variants
var/themes_repository/responsive/templates/addons/kv_frequently_bought_together/ Product and cart block templates and hooks
js/addons/kv_frequently_bought_together/func.js Checkbox totals, beacon, cart block (jQuery, no external libraries)