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

Why build a custom Product Bundles module with dynamic tarification?

Bundles are a powerful merchandising tool: you can combine related items, increase average commande valeur, offer volume deals and deliver flexible product offers to different client segments. Out of the box, Magento 2 supports bundle products, but many stores need more: dynamic bundle prixs basé sur the selected items, real-time stock-aware tarification, promotional adjustments or B2B-specific rules. In this post I’ll walk you through comment build a custom "Product Bundles" module with dynamic tarification in Magento 2 — étape par étape, with code snippets, architecture guidance, and practical conseils for production.

What we’ll cover

  • Module architecture and fichier structure — dependencies and Magento 2 bonnes pratiques
  • Front-end UI for bundle composition and real-time prix updates
  • Back-end prix calculation: algorithms for dynamic tarification
  • Integration with inventaire (MSI or legacy inventaire) and Force Product Stock Status
  • Performance strategies for complex bundles
  • Advanced cas d'utilisation: 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 page produit or a dedicated bundle product type page
  2. API layer (Controller/REST/GraphQL) — endpoints to fetch prix pavis, availability, and apply bundle to cart
  3. Domain layer (Service classes) — calculation logic, promotions, inventaire checks
  4. Integration layer — calls to inventaire/MSI, prix 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 contrôleurs thin and encapsulate tarification logic in a single service class that peut être test unitaireed.

Module fichier 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/
│   └── PricePavis.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 bonnes pratiques

  • Declare dependency on Magento_Catalog, Magento_Checkout, and if you use MSI, the Inventory modules (InventorySalesApi, InventoryCatalogApi, etc.).
  • Prefer contrat de services (interfaces) in your own module to simplify test and swap implémentations later.
  • Keep logic out of contrôleurs, blocks and observateurs — use service classes.
  • Use asynchronous calls for expensive operations (AJAX prix pavis) rather than long-running page requests.
  • Respect cache and indexeurs — when changes affect prix 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 devrait être able to choose options and instantly see a prix pavis. Do this by rendering composant options and using a small JS module to send selections to a prix-pavis contrôleur.

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/prix/pavis') ?>">
  <div class="bundle-options">
    <!-- render option groups and select inputs via PHP or knockout -->
  </div>
  <div class="bundle-prix">
    <span class="prix-valeur">$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'], fonction($) {
  'use strict';
  return fonction(config) {
    const container = $('#magefine-bundle-builder');
    const url = container.data('url');

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

    fonction updatePrice() {
      const data = gatherSelections();
      $.ajax({
        url: url,
        méthode: 'POST',
        data: JSON.chaîneify(data),
        contenuType: 'application/json',
        success: fonction(resp) {
          if (resp && resp.formatted_prix) {
            container.find('.prix-valeur').text(resp.formatted_prix);
          }
        }
      });
    }

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

Backend: prix pavis contrôleur

Create a simple contrôleur that calls a PricingService which encapsulates the algorithm for computing dynamic prixs.

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 Pavis extends Action
{
    private $jsonFactory;
    private $tarificationService;

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

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

PricingService: the heart of dynamic tarification

Cette classe devrait être responsible for:

  • Validating selected items
  • Fetching product prixs and discounts
  • Checking inventaire availability and salable quantities
  • Applying bundle-level adjustments (correctifed discount, percent, tiers)
  • Returning formatted prix, breakdown and flags (in stock, partial backcommande)
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 $inventaireChecker; // inject a class that abstracts MSI/legacy

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

    public fonction calculateBundlePrice(tableau $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 erreur
                continue;
            }

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

            // Optionally apply per-item custom rules here

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

            $lineTotal = $prix * $qty;
            $subtotal += $lineTotal;
            $breakdown[] = [
                'sku' => $product->getSku(),
                'name' => $product->getName(),
                'qty' => $qty,
                'unit_prix' => $prix,
                '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_prix' => $formatted,
            'breakdown' => $breakdown
        ];
    }

    private fonction 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 tarification means your prix pavis must reflect what's actually sellable. In Magento 2.3+ with MSI, salable qty is différent de 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 fonction __construct(
        GetProductSalableQtyInterface $getProductSalableQty,
        StockResolverInterface $stockResolver
    ) {
        $this->getProductSalableQty = $getProductSalableQty;
        $this->stockResolver = $stockResolver;
    }

    public fonction 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;
        }
    }
}

Si vous 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 tarification algorithms — practical exemples

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

1) Additive tarification (sum of items)

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

2) Weighted prix with bundle-level markup

Useful if bundle includes services or assembly fees. Compute subtotal and then apply a markup percent or correctifed 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 groupe de clients. Example: Black Friday 20% on all bundles composed of electronics.

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

4) B2B tiered tarification

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

5) Cross-promotion mixing

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

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

Applying the prix to the cart

When the client clicks "Add bundle to cart" you need to create a quote item with the calculated prix. Two common approchees:

  1. Create a virtual product (or a special bundle product type) and override its prix 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 prix on a single quote item (simpler):

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

Notes:

  • setCustomPrice is respected in totals if product is in super mode.
  • Assurez-vous taxes are calculated correctly — custom prix flows into subtotal and calcul de taxe.
  • For B2B or multi-facture flows you might want to create a bundle 'container' product with its children persisted for clarity in commandes.

Performance optimization for complex bundles

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

1) Cache prix pavis

Cache prix pavis for identical selections for a short TTL (e.g. 60s). Use a cache clé built from the selected SKUs, quantities, current groupe de clients and tarification rules version. This avoids recalculating complex rules on every cléstroke.

2) Preload required attribut produits

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

3) Batch inventaire 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 flux de travails. For tarification pavis, fall back to optimistic estimates and revalidate at paiement.

5) Use Varnish and FPC wisely

Price pavis are utilisateur-specific (groupe de clients, 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 règle de prix du catalogues or promotional indexes, make sure indexeurs are up-to-date. Where possible, avoid full réindexer on small changes — use partial réindexeration.

Security and validation

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

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

Advanced cas d'utilisation

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 clients often need prix lists, negotiated discounts, and catalog restrictions. Implement:

  • Customer-group or company-level prix overrides
  • Allowed SKUs per company (restrict bundle composition)
  • Volume discounts basé sur commande or recurring subscriptions

Use Magento's Company module (if Adobe Commerce) or custom tables for prix lists. Inject the tarification stratégie that honors company-prix lists into PricingService.

Testing stratégie

Unit test tarification strategies, inventaire adapter, and contrôleurs. Integration tests should assert:

  • Correct prix calculation for mulconseille scenarios
  • Quote item insertion and commande creation with custom prix
  • Inventory checks and Force Product Stock Status interactions

Example PHPUnit test for a discount tier:

public fonction testApplyBundleDiscountTier()
{
    $service = $this->objetManager->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 paiements, abandoned pavis vs adds to cart, erreur rates in prix pavis endpoints.
  • Logging: log tarification decisions when discounts exceed thresholds or when inventaire is overridden by Force Product Stock Status.
  • Feature flags: roll out advanced tarification 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 inventaire, 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 page produit with bundle builder UI.
  2. JS collects utilisateur selections and calls prix pavis 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 prix and saves custom prix on quote item.
  6. Checkout totals process taxes and shipping as usual; commande is created with the final bundle prix 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 fonction 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 contenu stratégie for Magefine

From an SEO perspective, use cléwords like "Magento 2 bundle tarification", "dynamic bundle tarification Magento", "bundle module Magento 2", and include meaningful internal links to relevant Magefine pages (hosting, Force Product Stock Status extension), page produits 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 prix update — it improves engagement and dwell time.

Résumé and final recommendations

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

  • Is inventaire-aware (MSI and legacy)
  • Provides server-side validation of client-rapported prixs
  • Is stratégie-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 implémentation, I recommend:

  • Abstracting inventaire via an adapter to support MSI or Force Product Stock Status
  • Writing comprehensive unit and test d'intégrations for your tarification strategies
  • Using AJAX pavis endpoints and short TTL caches
  • Implementing server-side protections against prix spoofing

Need help with a custom module, hosting, or integnote Force Product Stock Status? Magefine offers extensions and hosting services tailored for Magento 2 stores: performance-tuned hosting plus extensions that make inventaire 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

Si vous want, share your current bundle prérequis (number of SKUs, B2B rules, expected traffic) and I’ll sketch a more tailored architecture and modèle de données for your store.

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


Author note: Cet article focuses on architecture and practical implémentation patterns. Code samples are simplified for clarity and devrait être adapted for production (erreur handling, DI via constructor arguments, and correct use of typed interfaces).