Bundle Products in Magento 2: Options, Pricing Logic and Stock Linkage
AI generated
M2
di.xml
Magento 2 · Bundle Products · Hyvä · PHP 8.4
Bundle Products in Magento 2
Options, Pricing Logic and Stock Linkage in Detail

Bundle products are among the most complex product types in Magento 2: they combine a dedicated table model of options and selections, two fundamentally different pricing logics for Fixed and Dynamic Pricing, a multi-layer stock linkage through Multi Source Inventory, and their own price index. This article explains the data model, price calculation, stock linkage, Hyvä frontend and performance strategies for bundle products using real PHP 8.4 code, Service Contracts and declarative schema.

18 min read Bundle Products · Price Index · MSI · Hyvä · Alpine.js Magento 2.4.8-p4 · PHP 8.4

1. What bundle products in Magento 2 really solve

Bundle products solve a concrete problem: a customer should be able to buy an individually configured combination of several independent components, where each component remains a standalone product with its own SKU and its own stock management. Typical use cases are a PC configurator where processor, memory and case can be freely combined, or a gift set where the customer chooses between several scents and packaging sizes. The decisive difference to a simple product with custom options is that every choice in a bundle points to a real catalog product with its own SKU, its own stock level and its own pricing logic.

Compared to a configurable product, the difference is that configurable represents variations of the same product, for example size and color of a T-shirt, where in the end exactly one simple product is sold. Bundle products, on the other hand, sell several different products at once, grouped into options with their own selection rules such as required, multiple selection or changeable quantity. Compared to a grouped product, which merely offers a fixed list of products on a shared page without the customer choosing anything, bundles bring real decision logic and their own price calculation.

When to avoid bundle products: if it is only about selecting a variation, a configurable product is the right and significantly easier to index solution. If the same fixed product combination should always be sold without the customer changing anything, a grouped product or a dedicated kit simple product with a fixed bill of materials is sufficient. The added complexity from option tables, price index and MSI linkage should be justified by genuine configuration needs, otherwise it creates unnecessary maintenance overhead without added value for the customer.

2. Data model: bundle_option, bundle_option_value, bundle_selection

Technically, "bundle" is its own product entity type in Magento 2, stored in the type_id field of catalog_product_entity, implemented via Magento\Bundle\Model\Product\Type as an extension of Magento\Catalog\Model\Product\Type\AbstractType. Unlike plain EAV attributes, the configurable parts of a bundle product live in three dedicated tables that are not part of the generic EAV system but classic relational tables with foreign keys: catalog_product_bundle_option for the option definition, catalog_product_bundle_option_value for the store-view-specific option titles, and catalog_product_bundle_selection for the actual selectable child products.

The option defines type (select, radio, checkbox, multi), required status and position, referencing the bundle product in catalog_product_entity via parent_id. The selection references the associated option via option_id and any independent simple product via product_id, complemented by selection_price_type, selection_price_value, selection_qty and selection_can_change_qty. This exact separation is what makes bundle products powerful: a simple product can appear as a selection in several bundles simultaneously without being duplicated, and keeps its own stock level and its own price management.

A simplified excerpt of the declarative schema shows the central columns and foreign keys as defined in the db_schema.xml of Magento_Bundle:


<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">

    <table name="catalog_product_bundle_option" resource="default" engine="innodb" comment="Catalog Product Bundle Option">
        <column xsi:type="int" name="option_id" unsigned="true" nullable="false" identity="true" comment="Option ID"/>
        <column xsi:type="int" name="parent_id" unsigned="true" nullable="false" identity="false" comment="Parent Product ID"/>
        <column xsi:type="boolean" name="required" nullable="false" default="1" comment="Required"/>
        <column xsi:type="varchar" name="type" nullable="false" length="255" comment="select, radio, checkbox, multi"/>
        <column xsi:type="int" name="position" unsigned="true" nullable="false" default="0" comment="Position"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="option_id"/>
        </constraint>
        <constraint xsi:type="foreign" referenceId="CAT_PRD_BNDL_OPT_PARENT_ID_CAT_PRD_ENTT_ENTT_ID"
                    table="catalog_product_bundle_option" column="parent_id"
                    referenceTable="catalog_product_entity" referenceColumn="entity_id" onDelete="CASCADE"/>
    </table>

    <table name="catalog_product_bundle_selection" resource="default" engine="innodb" comment="Catalog Product Bundle Selection">
        <column xsi:type="int" name="selection_id" unsigned="true" nullable="false" identity="true" comment="Selection ID"/>
        <column xsi:type="int" name="option_id" unsigned="true" nullable="false" identity="false" comment="Option ID"/>
        <column xsi:type="int" name="parent_product_id" unsigned="true" nullable="false" identity="false" comment="Parent Product ID"/>
        <column xsi:type="int" name="product_id" unsigned="true" nullable="false" identity="false" comment="Child Product ID"/>
        <column xsi:type="decimal" name="selection_price_value" scale="4" precision="12" nullable="true" comment="Selection Price Value"/>
        <column xsi:type="smallint" name="selection_price_type" unsigned="true" nullable="true" comment="0 = percent, 1 = fixed"/>
        <column xsi:type="decimal" name="selection_qty" scale="4" precision="12" nullable="true" default="1.0000" comment="Selection Qty"/>
        <column xsi:type="boolean" name="selection_can_change_qty" nullable="false" default="1" comment="Selection Can Change Qty"/>
        <column xsi:type="boolean" name="is_default" nullable="false" default="0" comment="Is Default"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="selection_id"/>
        </constraint>
    </table>

</schema>

3. Pricing logic: Fixed vs. Dynamic Pricing in detail

The entire price calculation of bundle products hinges on the price_type attribute, which knows exactly two states: 0 for Dynamic and 1 for Fixed. With Dynamic Pricing, the bundle product itself carries no meaningful base price, the final price results exclusively from the sum of the currently selected selections, whose individual price is either taken from the referenced simple product or adjusted percentage-wise or as a fixed amount via selection_price_type and selection_price_value. With Fixed Pricing, on the other hand, the bundle product carries its own fixed base price, to which the selections only contribute surcharges or discounts, independent of the catalog price of the referenced child product. Responsible for this branching is Magento\Bundle\Model\Product\Price, which follows completely different calculation paths for getPrice() and getFinalPrice() depending on price_type.

A second, independent attribute called price_view only controls the display on the catalog and product detail page, not the actual calculation: the value 0 shows "as low as", i.e. only the lowest possible total price across all required options with their cheapest selection, while value 1 outputs a price range from minimum to maximum. This distinction directly affects the price index, because for "as low as" prices only the minimum value needs to be precomputed, whereas for price ranges the maximum value across all possible combinations must also be precomputed.

In checkout and in the configuration preview, a chain of Magento\Bundle\Pricing\Price classes such as BundleOptionPrice and ConfiguredPrice takes over the concrete calculation for the combination actually chosen by the customer, in contrast to the catalog-wide preview, which must account for all theoretically possible combinations. Tax classes can differ per child product, which for bundle products with Fixed Pricing leads to a proportional tax split based on the individual selection prices, not based on a single tax rate for the entire bundle.

4. Creating options and selections programmatically

For programmatic maintenance of bundle products, Magento provides Service Contracts that make preferences and direct resource model access unnecessary: Magento\Bundle\Api\ProductOptionRepositoryInterface for saving options, Magento\Bundle\Api\Data\OptionInterfaceFactory for creating new option objects, and Magento\Bundle\Api\ProductLinkManagementInterface for adding individual selections to an existing option. Combined with Magento\Catalog\Api\ProductRepositoryInterface for loading the bundle product, a clean, testable flow emerges without direct SQL access to the tables described above.

The following example uses PHP 8.4 with constructor property promotion and strict_types, as prescribed by the coding standard for Mironsoft modules, and creates a required option of type "select" with two selections:


<?php

declare(strict_types=1);

namespace Mironsoft\CatalogSetup\Service;

use Magento\Bundle\Api\Data\LinkInterfaceFactory;
use Magento\Bundle\Api\Data\OptionInterfaceFactory;
use Magento\Bundle\Api\ProductLinkManagementInterface;
use Magento\Bundle\Api\ProductOptionRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Exception\InputException;
use Magento\Framework\Exception\NoSuchEntityException;

/**
 * Creates a bundle option with selections on an existing bundle product via Service Contracts.
 */
final class BundleOptionBuilder
{
    /**
     * @param ProductRepositoryInterface $productRepository Loads the target bundle product.
     * @param OptionInterfaceFactory $optionFactory Factory for bundle option data objects.
     * @param LinkInterfaceFactory $linkFactory Factory for bundle selection (link) data objects.
     * @param ProductOptionRepositoryInterface $optionRepository Persists the bundle option.
     * @param ProductLinkManagementInterface $linkManagement Persists selections for an option.
     */
    public function __construct(
        private readonly ProductRepositoryInterface $productRepository,
        private readonly OptionInterfaceFactory $optionFactory,
        private readonly LinkInterfaceFactory $linkFactory,
        private readonly ProductOptionRepositoryInterface $optionRepository,
        private readonly ProductLinkManagementInterface $linkManagement,
    ) {
    }

    /**
     * Adds one required select option with the given child selections to a bundle product.
     *
     * @param string $bundleSku SKU of the existing bundle product.
     * @param string $title Storefront title of the new option.
     * @param array<int, array{sku: string, priceType: int, priceValue: float, qty: float, isDefault: bool}> $selections Child products.
     * @return void
     * @throws NoSuchEntityException
     * @throws InputException
     */
    public function addSelectOption(string $bundleSku, string $title, array $selections): void
    {
        /** @var ProductInterface $bundle */
        $bundle = $this->productRepository->get($bundleSku, true);

        $option = $this->optionFactory->create();
        $option->setTitle($title);
        $option->setType('select');
        $option->setRequired(true);
        $option->setPosition(0);
        $option->setSku($bundleSku);

        $option = $this->optionRepository->save($bundle, $option);

        foreach ($selections as $position => $data) {
            $link = $this->linkFactory->create();
            $link->setSku($data['sku']);
            $link->setOptionId((int) $option->getOptionId());
            $link->setPriceType($data['priceType']);
            $link->setPrice($data['priceValue']);
            $link->setQty($data['qty']);
            $link->setIsDefault($data['isDefault']);
            $link->setPosition($position);
            $link->setCanChangeQuantity(true);

            // addChild persists the row in catalog_product_bundle_selection
            $this->linkManagement->addChild($bundle, (string) $option->getOptionId(), $link);
        }
    }
}

Important with this approach: ProductRepositoryInterface::get() must be called with the second parameter true to load the product in edit mode, otherwise the subsequent option save fails. For bulk imports of many bundle products, it is recommended to deliberately run the product cache and the price index in schedule mode, so that not every single save() call triggers a full reindex, see section 8.

5. Stock linkage: MSI, stock reservations and salable quantity

The link between a bundle selection and the actual stock level runs through Magento\Bundle\Model\LinkManagement, whose methods saveChild and getChildren manage the rows in catalog_product_bundle_selection. Important: the bundle product itself usually carries no own physical stock, it is configured with "Manage Stock" disabled. The actual availability results as a derived state from the stock levels of all child products that can be selected in the currently required options.

Under Multi Source Inventory, the salable quantity of every individual selection is determined via Magento\InventorySalesApi\Api\GetProductSalableQtyInterface per source and stock, exactly as with any other simple product. For bundle products this means: for the bundle to be considered salable, at least one selection in every required option must show a positive salable quantity; for options of type checkbox or multi with several simultaneously selectable child products, one available selection from the group is sufficient. This combination logic is not represented in the MSI core tables themselves but calculated in the bundle-specific availability check, which builds on the results of the simple product checks.

On purchase, reservations are created exclusively for the child products, not for the bundle itself: for every purchased selection, Magento creates an entry in inventory_reservation, whose quantity results from the bundle order quantity multiplied by selection_qty of the respective choice. This also means that backorders, minimum order quantities and source prioritization are configured exclusively at the child product level, the bundle product itself has no own stock configuration in this context beyond pure visibility control.

6. Frontend rendering: bundle options in the Hyvä theme

In the Hyvä theme, the classic Luma pattern from bundle.js and Knockout templates disappears entirely, instead Alpine.js takes over the complete client-side state management for the selected options and the live recalculation of the total price. The price data of all selections is written server-side in the phtml template via $block->getJsonConfig() as JSON into the x-data attribute, so that no separate JavaScript bundle needs to be loaded for the pricing logic of bundle products, and no jQuery and no Knockout.js are involved.

The following template shows the basic structure: for every option, depending on the type, a radio button or a checkbox is rendered, the selected value lands directly in the Alpine state via x-model, a computed getter total sums the base price and all currently selected selection prices:


<?php
/** @var \Magento\Catalog\Block\Product\View\Type\Bundle $block */
/** @var \Magento\Framework\Escaper $escaper */
?>
<div x-data="{
        selected: {},
        prices: <?= /* @noEscape */ $block->getJsonConfig() ?>,
        basePrice: <?= (float) $block->getProduct()->getFinalPrice() ?>,
        get total() {
            return this.basePrice + Object.values(this.selected)
                .reduce((sum, id) => sum + (this.prices[id] || 0), 0);
        },
        formatPrice(value) {
            return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(value);
        }
     }"
     class="bundle-options"
>
    <?php foreach ($block->getOptions() as $option): ?>
        <div class="mb-6">
            <p class="font-semibold mb-2"><?= $escaper->escapeHtml($option->getTitle()) ?></p>
            <?php foreach ($option->getSelections() as $selection): ?>
                <label class="flex items-center gap-2 mb-1">
                    <input
                        type="radio"
                        name="bundle_option_<?= (int) $option->getOptionId() ?>"
                        value="<?= (int) $selection->getSelectionId() ?>"
                        x-model="selected[<?= (int) $option->getOptionId() ?>]"
                    >
                    <span><?= $escaper->escapeHtml($selection->getName()) ?></span>
                    <span class="text-sm text-gray-500" x-text="formatPrice(prices[<?= (int) $selection->getSelectionId() ?>])"></span>
                </label>
            <?php endforeach; ?>
        </div>
    <?php endforeach; ?>

    <p class="text-lg font-bold" x-text="formatPrice(total)"></p>
</div>

As long as the entire logic fits as an expression in x-data, no additional inline script block is needed. If the calculation for more complex bundle products grows beyond that, for example with tiered quantity discounts per selection, the logic is extracted into its own Alpine.data() component, either as a separate JS file in the theme or as an inline script block in the template. In the latter case, the call $hyvaCsp->registerInlineScript() is mandatory immediately after the closing </script> tag, so that the Content Security Policy does not block the block.

7. Checkout and cart: bundle selections as a buy request

When a customer adds a bundle product to the cart, all choices made are bundled into what is called a buy request, an associative array that hangs as a serialized option under the key info_buyRequest on the quote item. The key bundle_option contains, per option ID, either a single selection ID for select or radio, or an array of selection IDs for checkbox and multi. The optional key bundle_option_qty overrides the default quantity of a selection, but only if selection_can_change_qty is enabled for that choice.

This structure is evaluated directly by Magento\Bundle\Model\Product\Type::processConfiguration() to generate concrete quote item children from the buy request. A realistic buy request for a bundle product with three options, one of which is a multiple selection, looks like this:


{
    "product": 1234,
    "selected_configurable_option": "",
    "related_product": "",
    "qty": "1",
    "bundle_option": {
        "1": "5",
        "2": ["8", "9"],
        "3": "12"
    },
    "bundle_option_qty": {
        "3": "2"
    }
}

Upon order completion, a separate order item of type "simple" is created for every chosen child product, referencing the parent order item of type "bundle" via parent_item_id. The quantity of every child order item is calculated from the ordered bundle quantity multiplied by selection_qty of the respective choice, or the overridden value from bundle_option_qty. In addition, Magento stores a complete snapshot of price, quantity and weight at order time in the serialized column product_options of sales_order_item, so that later changes to the bundle product definitions or to individual selections do not alter the historical order data.

8. Performance: bundle price index and reindex strategies

For bundle products, two dedicated price index tables exist: catalog_product_bundle_price_index stores precomputed minimum and maximum prices per customer group and website, catalog_product_bundle_selection_price_index holds the precomputed prices of the individual selections within these combinations. The reason for this precomputation: without an index, every catalog page and every product detail page would need to recalculate the complete price matrix from all required options and their cheapest or most expensive selections at runtime, which quickly becomes combinatorially expensive for many options with many selections each.

A practical note for shops with many option combinations: Fixed Pricing requires significantly less computational effort for indexing than Dynamic Pricing, because with a fixed base price only the surcharges of the selections flow into the min/max calculation, while Dynamic Pricing must rebuild the complete sum for every conceivable combination. For bundle products with very many required options and a double-digit number of selections each, it is therefore advisable, where business-wise justifiable, to switch to Fixed Pricing, or to deliberately limit the number of simultaneously indexed combinations.

For ongoing operations, the catalog_product_price indexer should not run in "Update on Save" mode but should be switched to cron-based schedule mode via bin/magento indexer:set-mode schedule catalog_product_price, so that a single product save with many selections does not block the admin request. Anyone who wants to extend the price calculation of an individual selection with their own business rules, for example tiered discounts per customer group, attaches a plugin to Magento\Bundle\Model\Product\Price instead of replacing the class via a preference:


<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">

    <type name="Magento\Bundle\Model\Product\Price">
        <plugin name="mironsoft_custom_bundle_selection_price"
                type="Mironsoft\CatalogSetup\Plugin\CustomBundleSelectionPricePlugin"
                sortOrder="10"/>
    </type>

</config>

After a larger import or a bulk change to bundle products, it is also worth checking bin/magento indexer:status to see whether the price index still shows "Invalid" and whether a manual reindex with bin/magento indexer:reindex catalog_product_price is advisable before storefront prices become visibly outdated.

9. Bundle products compared: Fixed vs. Dynamic vs. other product types

The choice between the two pricing variants of bundle products and the other configurable product types in Magento 2 has direct effects on price calculation, stock linkage, indexing effort and the appropriate use case. The following table compares the four most important variants.

Dimension Bundle Fixed Bundle Dynamic Configurable Grouped
Price calculation Fixed base price plus selection surcharges Sum of the selected selection prices Price of the chosen variant (simple product) Sum of independent individual prices
Stock linkage Via selections to child products Via selections to child products Directly on the chosen simple product Directly on every linked product
Search indexing Min/max via surcharges, moderate effort Min/max via full combination sum, high effort Min/max via variant prices, low effort No price index, every product indexed individually
Use case Configurator with a stable base price Configurator with a variable total price Variants of the same product Fixed product group without selection

In practice, the deciding factor is usually the degree of price flexibility the customer should see: a PC configurator with strongly fluctuating component prices fits Dynamic Pricing, a gift set with a fixed selling price and small surcharges for premium variants fits Fixed Pricing better. Bundle products should never be used as a substitute for simple variant selection, for that Configurable remains the leaner and better indexable solution.

10. Summary

Bundle products in Magento 2 solve a clearly delimited problem: combining independent, individually stock-managed products into a customer-configurable unit, with its own pricing logic depending on Fixed or Dynamic Pricing mode. The data model of option, option value and selection cleanly separates option structure, store-view-specific titles, and the actual product linkage. The price calculation depends entirely on the price_type attribute, while price_view only controls the display as an "as low as" price or a price range.

Stock linkage consistently runs through the child products, the bundle itself carries no own physical stock, reservations are created exclusively for the chosen selections. In the Hyvä frontend, Alpine.js takes over the complete live price calculation without jQuery and without Knockout, and in checkout the selection lands as a structured buy request on the quote item. For performance with many option combinations, the dedicated bundle price index and a cron-based schedule mode of the price indexer are decisive, so that bundle products stay fast even with complex configurations.

Bundle Products in Magento 2: The Essentials at a Glance

Data model

catalog_product_bundle_option, _option_value and _selection cleanly separate option structure, titles and product linkage.

Pricing logic

price_type decides Fixed vs. Dynamic, price_view only controls the "as low as" price or price range in the display.

Stock linkage

MSI calculates salable quantity per selection, reservations are created only for child products, never for the bundle itself.

Performance

Dedicated price index, prefer Fixed Pricing with many combinations, run the indexer in schedule mode.

11. FAQ: Bundle Products in Magento 2

1What are bundle products in Magento 2?
A dedicated product type where the customer selects one or more independent, individually stock-managed products from several options, for example in a PC configurator or gift set.
2Difference to configurable products?
Configurable represents variations of the same product, in the end one simple product is sold. Bundle sells several different products at once, grouped into options.
3Which tables store options and selections?
catalog_product_bundle_option for the option definition, catalog_product_bundle_option_value for titles, catalog_product_bundle_selection for the selectable child products.
4What does price_type mean?
Controls Fixed (1) versus Dynamic (0) Pricing. Fixed uses its own base price, Dynamic sums exclusively the selected selection prices.
5How do you create options programmatically?
Via ProductOptionRepositoryInterface, OptionInterfaceFactory and ProductLinkManagementInterface, combined with ProductRepositoryInterface to load in edit mode.
6How does stock linkage work under MSI?
Every selection is checked like a simple product via GetProductSalableQtyInterface. The bundle is salable if every required option has at least one available selection.
7How do you render options in the Hyvä theme?
Write selection prices as JSON via getJsonConfig into x-data, Alpine.js takes over selection and live price calculation without jQuery and without Knockout.js.
8How are selections stored in the cart?
As a buy request under info_buyRequest, with bundle_option per option ID and optionally bundle_option_qty for changeable quantities.
9Which index tables are responsible?
catalog_product_bundle_price_index for min/max per customer group and website, catalog_product_bundle_selection_price_index for the individual selection prices.
10Why is Fixed Pricing more performant?
Only the surcharges flow into the min/max calculation. Dynamic Pricing must rebuild the full sum for every possible combination, which is noticeably more expensive with many selections.

Mironsoft

Magento 2 development, Hyvä theming and performance optimization

Bundle products that calculate reliably and load fast?

We build and optimize bundle products in Magento 2: a clean data model, correct MSI stock linkage, a Hyvä frontend with Alpine.js, and a price index that stays performant even with many option combinations.

Bundle setup

Modeling options, selections and pricing logic to requirements and maintaining them programmatically

Hyvä frontend

Alpine.js-based bundle selection with live price calculation, no jQuery and no Knockout

Performance audit

Price index analysis, reindex strategy and indexer modes for large bundle catalogs