How to Build a Custom Product Bundles Module with Dynamic Pricing in Magento 2

Why build a custom Product Bundles module with dynamic pricing?

Bundles are a powerful merchandising tool: you can combine related items, increase average order value, offer volume deals and deliver flexible product offers to different customer segments. Out of the box, Magento 2 supports bundle products, but many stores need more: dynamic bundle prices based on the selected items, real-time stock-aware pricing, promotional adjustments or B2B-specific rules. In this post I’ll walk you through how to build a custom "Product Bundles" module with dynamic pricing in Magento 2 — step by step, with code snippets, architecture guidance, and practical tips for production.

What we’ll cover

  • Module architecture and file structure — dependencies and Magento 2 best practices
  • Front-end UI for bundle composition and real-time price updates
  • Back-end price calculation: algorithms for dynamic pricing
  • Integration with inventory (MSI or legacy inventory) and Force Product Stock Status
  • Performance strategies for complex bundles
  • Advanced use cases: seasonal bundles, cross-promotions and B2B personalization

High-level architecture

Keep the module modular. The main parts are:

  1. UI layer (Block, template, JS) — bundle builder on product page or a dedicated bundle product type page
  2. API layer (Controller/REST/GraphQL) — endpoints to fetch price previews, availability, and apply bundle to cart
  3. Domain layer (Service classes) — calculation logic, promotions, inventory checks
  4. Integration layer — calls to inventory/MSI, price rules, tax and currency services
  5. Persistence and config — DB tables for custom bundle definitions, admin UI data

We’ll use DI-friendly services, keep controllers thin and encapsulate pricing logic in a single service class that can be unit tested.

Module file structure (suggested)

Create a vendor namespace like Magefine/BundleDynamic. Minimal structure:

app/code/Magefine/BundleDynamic/
├── etc/
│   ├── module.xml
│   ├── di.xml
│   └── routes.xml
├── registration.php
├── composer.json
├── Controller/
│   └── PricePreview.php
├── Model/
│   ├── PricingService.php
│   ├── BundleRepository.php
│   └── Validator/BundleValidator.php
├── view/frontend/templates/
│   └── bundle_builder.phtml
├── view/frontend/web/js/
│   └── bundle-builder.js
├── etc/adminhtml/
│   └── acl.xml
└── Setup/InstallSchema.php (or db_schema.xml for declarative schema)

Use declarative schema where possible (db_schema.xml) and avoid InstallSchema unless necessary.

Important dependencies and best practices

  • Declare dependency on Magento_Catalog, Magento_Checkout, and if you use MSI, the Inventory modules (InventorySalesApi, InventoryCatalogApi, etc.).
  • Prefer service contracts (interfaces) in your own module to simplify testing and swap implementations later.
  • Keep logic out of controllers, blocks and observers — use service classes.
  • Use asynchronous calls for expensive operations (AJAX price preview) rather than long-running page requests.
  • Respect cache and indexers — when changes affect price logic, invalidate only what's necessary.

Minimal module registration and module.xml

registration.php

<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
    ComponentRegistrar::MODULE,
    'Magefine_BundleDynamic',
    __DIR__
);

etc/module.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
  <module name="Magefine_BundleDynamic" setup_version="1.0.0">
    <sequence>
      <module name="Magento_Catalog" />
      <module name="Magento_Checkout" />
    </sequence>
  </module>
</config>

Front-end bundle builder UI

Users should be able to choose options and instantly see a price preview. Do this by rendering component options and using a small JS module to send selections to a price-preview controller.

view/frontend/templates/bundle_builder.phtml (simplified)

<?php /* @var $block \Magento\Framework\View\Element\Template */ ?>
<div id="magefine-bundle-builder" data-url="<?= $block->getUrl('bundledynamic/price/preview') ?>">
  <div class="bundle-options">
    <!-- render option groups and select inputs via PHP or knockout -->
  </div>
  <div class="bundle-price">
    <span class="price-value">$0.00</span>
  </div>
  <button class="add-to-cart" type="button">Add bundle to cart</button>
</div>

view/frontend/web/js/bundle-builder.js (simplified)

define(['jquery'], function($) {
  'use strict';
  return function(config) {
    const container = $('#magefine-bundle-builder');
    const url = container.data('url');

    function gatherSelections() {
      // gather product ids and qtys from the UI
      return { items: [ /* {sku/id, qty} */ ] };
    }

    function updatePrice() {
      const data = gatherSelections();
      $.ajax({
        url: url,
        method: 'POST',
        data: JSON.stringify(data),
        contentType: 'application/json',
        success: function(resp) {
          if (resp && resp.formatted_price) {
            container.find('.price-value').text(resp.formatted_price);
          }
        }
      });
    }

    container.on('change', 'select,input', updatePrice);
    updatePrice();
  }
});

Backend: price preview controller

Create a simple controller that calls a PricingService which encapsulates the algorithm for computing dynamic prices.

namespace Magefine\BundleDynamic\Controller\Price;

use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\Webapi\Rest\Response\JsonFactory;
use Magefine\BundleDynamic\Model\PricingService;

class Preview extends Action
{
    private $jsonFactory;
    private $pricingService;

    public function __construct(Context $context, JsonFactory $jsonFactory, PricingService $pricingService)
    {
        parent::__construct($context);
        $this->jsonFactory = $jsonFactory;
        $this->pricingService = $pricingService;
    }

    public function execute()
    {
        $request = $this->getRequest();
        $payload = json_decode($request->getContent(), true);
        $result = $this->pricingService->calculateBundlePrice($payload);
        $response = $this->jsonFactory->create();
        return $response->setData($result);
    }
}

PricingService: the heart of dynamic pricing

This class should be responsible for:

  • Validating selected items
  • Fetching product prices and discounts
  • Checking inventory availability and salable quantities
  • Applying bundle-level adjustments (fixed discount, percent, tiers)
  • Returning formatted price, breakdown and flags (in stock, partial backorder)
namespace Magefine\BundleDynamic\Model;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Store\Model\StoreManagerInterface;
use Magento\Directory\Model\CurrencyFactory;

class PricingService
{
    private $productRepository;
    private $storeManager;
    private $currencyFactory;
    private $inventoryChecker; // inject a class that abstracts MSI/legacy

    public function __construct(
        ProductRepositoryInterface $productRepository,
        StoreManagerInterface $storeManager,
        CurrencyFactory $currencyFactory,
        InventoryCheckerInterface $inventoryChecker
    ) {
        $this->productRepository = $productRepository;
        $this->storeManager = $storeManager;
        $this->currencyFactory = $currencyFactory;
        $this->inventoryChecker = $inventoryChecker;
    }

    public function calculateBundlePrice(array $payload)
    {
        $items = $payload['items'] ?? [];
        $subtotal = 0;
        $breakdown = [];

        foreach ($items as $line) {
            $skuOrId = $line['sku'] ?? $line['id'] ?? null;
            $qty = (float)($line['qty'] ?? 1);
            if (!$skuOrId) {
                continue;
            }

            try {
                $product = $this->productRepository->get($skuOrId);
            } catch (\Exception $e) {
                // product not found, skip or mark error
                continue;
            }

            $price = (float)$product->getFinalPrice($qty);

            // Optionally apply per-item custom rules here

            $salable = $this->inventoryChecker->getSalableQty($product->getSku());
            $inStock = $salable >= $qty;

            $lineTotal = $price * $qty;
            $subtotal += $lineTotal;
            $breakdown[] = [
                'sku' => $product->getSku(),
                'name' => $product->getName(),
                'qty' => $qty,
                'unit_price' => $price,
                'line_total' => $lineTotal,
                'in_stock' => $inStock,
                'salable_qty' => $salable
            ];
        }

        // Example: dynamic discount tiers
        $discount = $this->applyBundleDiscount($subtotal, count($items));
        $total = max(0, $subtotal - $discount);

        $store = $this->storeManager->getStore();
        $formatted = $store->getCurrentCurrency()->formatPrecision($total, 2, [], false);

        return [
            'subtotal' => $subtotal,
            'discount' => $discount,
            'total' => $total,
            'formatted_price' => $formatted,
            'breakdown' => $breakdown
        ];
    }

    private function applyBundleDiscount($subtotal, $itemsCount)
    {
        // Simple algorithm: more items = higher percent discount
        if ($itemsCount >= 5) {
            return $subtotal * 0.15; // 15% for 5+
        }
        if ($itemsCount >= 3) {
            return $subtotal * 0.08; // 8% for 3-4
        }
        return 0;
    }
}

Inventory integration: MSI and Force Product Stock Status

Inventory-aware pricing means your price preview must reflect what's actually sellable. In Magento 2.3+ with MSI, salable qty is different from physical stock. Use the InventorySalesApi's GetProductSalableQtyInterface for store-level salable quantity.

namespace Magefine\BundleDynamic\Model\Inventory;

use Magento\InventorySalesApi\Api\GetProductSalableQtyInterface;
use Magento\InventorySalesApi\Api\StockResolverInterface;

class MsiInventoryChecker implements InventoryCheckerInterface
{
    private $getProductSalableQty;
    private $stockResolver;

    public function __construct(
        GetProductSalableQtyInterface $getProductSalableQty,
        StockResolverInterface $stockResolver
    ) {
        $this->getProductSalableQty = $getProductSalableQty;
        $this->stockResolver = $stockResolver;
    }

    public function getSalableQty($sku)
    {
        // resolve default stock for current website
        $stock = $this->stockResolver->execute('websiteCode');
        $stockId = $stock->getStockId();
        try {
            return $this->getProductSalableQty->execute($sku, $stockId);
        } catch (\Exception $e) {
            return 0;
        }
    }
}

If you have the Force Product Stock Status extension (a Magefine extension), integrate naturally: consult that extension's public APIs or DB flags to determine forced stock states (e.g. "always in stock" for certain SKUs). The integration pattern is the same: provide an adapter that checks extension settings and returns the effective salable flags.

Dynamic pricing algorithms — practical examples

Let’s explore a few algorithms you might implement. Keep these algorithms in separate services or strategy classes so you can add or swap them easily.

1) Additive pricing (sum of items)

Simple and predictable: price = sum(item price * qty) - optional bundle discount. Implemented above.

2) Weighted price with bundle-level markup

Useful if bundle includes services or assembly fees. Compute subtotal and then apply a markup percent or fixed fee.

$markupPercent = 0.05; // 5% assembly/packaging
$total = $subtotal * (1 + $markupPercent);

3) Triggered rules (time-based or seasonal)

Apply different rules depending on date, coupon, or customer group. Example: Black Friday 20% on all bundles composed of electronics.

if ($this->isBlackFriday()) {
    $total = $subtotal * 0.8; // 20% off
}

4) B2B tiered pricing

For B2B, apply different price matrices depending on company account or negotiated price list (e.g. price list X). You can fetch custom price lists from a table and compute a line price using that matrix.

5) Cross-promotion mixing

When two SKUs are bought together, apply extra discount. Efficient implementation: compute a map of SKUs in selection, then check a promotions table for matching pairs and subtract appropriate discount.

// pseudo
$selectedSkus = array_column($items, 'sku');
foreach ($promotionPairs as $pair) {
    if (in_array($pair['sku_a'], $selectedSkus) && in_array($pair['sku_b'], $selectedSkus)) {
        $discount += $pair['discount_amount'];
    }
}

Applying the price to the cart

When the customer clicks "Add bundle to cart" you need to create a quote item with the calculated price. Two common approaches:

  1. Create a virtual product (or a special bundle product type) and override its price via quote item's setCustomPrice + setOriginalCustomPrice
  2. Persist a parent bundle product and add children as separate quote lines, tying them with a custom bundle_id quote attribute

Example using custom price on a single quote item (simpler):

$quoteItem = $quote->addProduct($product, $buyRequest);
$quoteItem->setCustomPrice($calculatedPrice);
$quoteItem->setOriginalCustomPrice($calculatedPrice);
$quoteItem->getProduct()->setIsSuperMode(true); // allow custom price
$quoteRepository->save($quote);

Notes:

  • setCustomPrice is respected in totals if product is in super mode.
  • Make sure taxes are calculated correctly — custom price flows into subtotal and tax calculation.
  • For B2B or multi-invoice flows you might want to create a bundle 'container' product with its children persisted for clarity in orders.

Performance optimization for complex bundles

Bundles with many options or many items require attention to performance. Here are proven techniques:

1) Cache price previews

Cache price previews for identical selections for a short TTL (e.g. 60s). Use a cache key built from the selected SKUs, quantities, current customer group and pricing rules version. This avoids recalculating complex rules on every keystroke.

2) Preload required product attributes

When fetching a list of products to compute a price, request only the necessary attributes and use collection loading when possible to minimize queries.

3) Batch inventory checks

MSI supports bulk retrieval of salable quantities. Avoid calling GetProductSalableQtyInterface repeatedly in a loop — instead group SKUs and call the API efficiently.

4) Move heavy tasks to background or async

If bundle validation requires long-running checks (e.g. external PIM or ERP calls), use asynchronous workflows. For pricing preview, fall back to optimistic estimates and revalidate at checkout.

5) Use Varnish and FPC wisely

Price previews are user-specific (customer group, cart context), so they should not be cached at full-page level. Instead, use AJAX endpoints and ensure Varnish caching rules allow short-lived cached responses when safe.

6) Indexer impact

If bundle computations rely on catalog price rules or promotional indexes, make sure indexers are up-to-date. Where possible, avoid full reindex on small changes — use partial reindexing.

Security and validation

Never trust client-submitted prices. Always validate at add-to-cart and during order placement on the server side. Typical checks:

  • Recompute price using server-side service
  • Re-check salable quantities
  • Validate coupon or promotion eligibility
public function beforeAddProductToCart($observer)
{
    $request = $observer->getEvent()->getRequest();
    $payload = $request->getParam('bundle_data');
    $serverComputed = $this->pricingService->calculateBundlePrice($payload);
    // if client price != server price, override the quote item with server price
}

Advanced use cases

Seasonal bundles

Seasonal bundles often require time-scoped rules and creative promotions. Implement a rule engine table where rules have start_date and end_date. Use cron tasks to pre-warm caches for expected seasonal bundles (e.g. Christmas combos).

Cross-promotion and multi-bundle discounts

Allow stacking rules like "Buy bundle A and B -> additional 10% off". Track bundle IDs on quote items and compute cross-bundle adjustments in totals collector.

B2B personalization

B2B customers often need price lists, negotiated discounts, and catalog restrictions. Implement:

  • Customer-group or company-level price overrides
  • Allowed SKUs per company (restrict bundle composition)
  • Volume discounts based on order or recurring subscriptions

Use Magento's Company module (if Adobe Commerce) or custom tables for price lists. Inject the pricing strategy that honors company-price lists into PricingService.

Testing strategy

Unit test pricing strategies, inventory adapter, and controllers. Integration tests should assert:

  • Correct price calculation for multiple scenarios
  • Quote item insertion and order creation with custom price
  • Inventory checks and Force Product Stock Status interactions

Example PHPUnit test for a discount tier:

public function testApplyBundleDiscountTier()
{
    $service = $this->objectManager->create(PricingService::class);
    $result = $service->calculateBundlePrice(['items' => [
        ['sku' => 'A', 'qty' => 1],
        ['sku' => 'B', 'qty' => 1],
        ['sku' => 'C', 'qty' => 1]
    ]]);
    $this->assertGreaterThan(0, $result['discount']);
}

Operational considerations

  • Monitoring: track bundle checkouts, abandoned previews vs adds to cart, error rates in price preview endpoints.
  • Logging: log pricing decisions when discounts exceed thresholds or when inventory is overridden by Force Product Stock Status.
  • Feature flags: roll out advanced pricing gradually using config flags or toggles.

Migration and compatibility

When upgrading Magento or enabling MSI on a previously non-MSI store, implement an adapter layer for inventory, so your PricingService calls a stable interface (InventoryCheckerInterface) rather than direct MSI or legacy classes. This reduces future migration work.

Putting it all together — a simple end-to-end flow

  1. User loads product page with bundle builder UI.
  2. JS collects user selections and calls price preview endpoint.
  3. Controller delegates to PricingService which:
    • Validates the payload
    • Loads product data in a batch
    • Checks salable quantities via InventoryChecker (MSI or legacy)
    • Computes discounts, markup, and promotions
    • Returns formatted total and breakdown
  4. JS updates UI with breakdown and Add to cart button becomes active.
  5. When adding to cart, server re-calculates price and saves custom price on quote item.
  6. Checkout totals process taxes and shipping as usual; order is created with the final bundle price and line items tracked.

Example: Cross-bundle discount collector (totals)

To implement a cross-bundle discount that acts at quote totals time, create a totals collector that inspects quote items for your bundle tag and applies an adjustment. Keep logic simple and well tested.

namespace Magefine\BundleDynamic\Model\Total;

use Magento\Quote\Model\Quote\Address\Total;

class BundleCrossDiscount extends \Magento\Quote\Model\Quote\Address\Total\AbstractTotal
{
    public function collect(\Magento\Quote\Model\Quote $quote, \Magento\Quote\Model\Quote\Address\Total $total)
    {
        parent::collect($quote, $total);
        $discount = 0;
        // inspect quote items and compute discount
        $total->setDiscountAmount($total->getDiscountAmount() + $discount);
        $total->setGrandTotal($total->getGrandTotal() - $discount);
        return $this;
    }
}

SEO and content strategy for Magefine

From an SEO perspective, use keywords like "Magento 2 bundle pricing", "dynamic bundle pricing Magento", "bundle module Magento 2", and include meaningful internal links to relevant Magefine pages (hosting, Force Product Stock Status extension), product pages and docs. In-page markup should include clear H2/H3 headings, code samples, and TL;DR sections for skimmability. Also consider a short video or GIF showing the dynamic price update — it improves engagement and dwell time.

Summary and final recommendations

Building a custom Product Bundles module with dynamic pricing is a manageable project if you keep concerns separated and implement a robust PricingService that:

  • Is inventory-aware (MSI and legacy)
  • Provides server-side validation of client-reported prices
  • Is strategy-driven so you can add seasonal, B2B and promotional rules easily
  • Uses caching, batching and async where necessary to stay performant

For a production-ready implementation, I recommend:

  • Abstracting inventory via an adapter to support MSI or Force Product Stock Status
  • Writing comprehensive unit and integration tests for your pricing strategies
  • Using AJAX preview endpoints and short TTL caches
  • Implementing server-side protections against price spoofing

Need help with a custom module, hosting, or integrating Force Product Stock Status? Magefine offers extensions and hosting services tailored for Magento 2 stores: performance-tuned hosting plus extensions that make inventory and product status easier to manage.

Further reading and references

  • Magento 2 Developer Documentation — module development, DI and plugin patterns
  • MSI (Multi-Source Inventory) APIs — GetProductSalableQtyInterface
  • Magefine Force Product Stock Status documentation (if using)

Contact

If you want, share your current bundle requirements (number of SKUs, B2B rules, expected traffic) and I’ll sketch a more tailored architecture and data model for your store.

Happy coding — and remember, keep pricing logic isolated and testable. That saves you from surprises at checkout.


Author note: This article focuses on architecture and practical implementation patterns. Code samples are simplified for clarity and should be adapted for production (error handling, DI via constructor arguments, and correct use of typed interfaces).