How to Build a Custom Product Bundle Module in Magento 2

Why Build a Custom Product Bundle Module in Magento 2?

Product bundles are a fantastic way to increase average order value (AOV) and offer customers more flexibility. While Magento 2 comes with a built-in bundle product type, sometimes you need more control—like dynamic pricing rules, conditional products, or custom display logic. That’s where building a custom product bundle module comes in handy.

In this guide, we’ll walk through creating a custom bundle module from scratch. No fluff, just practical steps with code examples.

Prerequisites

  • Magento 2.4.x installed
  • Basic understanding of Magento module structure
  • PHP and XML knowledge
  • A code editor (VS Code, PHPStorm, etc.)

Step 1: Create the Module Structure

First, let’s set up the basic module structure in app/code/Magefine/CustomBundle.


Magefine/
  CustomBundle/
    etc/
      module.xml
      di.xml
    Block/
    Controller/
    Model/
    Setup/
      InstallSchema.php
    view/
      frontend/
        layout/
        templates/
    registration.php

Step 2: Define the Module

Create registration.php:


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

Then, define the module in 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_CustomBundle" setup_version="1.0.0">
        <sequence>
            <module name="Magento_Catalog"/>
            <module name="Magento_Checkout"/>
        </sequence>
    </module>
</config>

Step 3: Create Database Tables

We’ll need a table to store bundle relationships. Create Setup/InstallSchema.php:


<?php
namespace Magefine\CustomBundle\Setup;

use Magento\Framework\Setup\InstallSchemaInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\SchemaSetupInterface;
use Magento\Framework\DB\Ddl\Table;

class InstallSchema implements InstallSchemaInterface
{
    public function install(SchemaSetupInterface $setup, ModuleContextInterface $context)
    {
        $installer = $setup;
        $installer->startSetup();

        $table = $installer->getConnection()->newTable(
            $installer->getTable('magefine_custom_bundle')
        )->addColumn(
            'bundle_id',
            Table::TYPE_INTEGER,
            null,
            ['identity' => true, 'unsigned' => true, 'nullable' => false, 'primary' => true],
            'Bundle ID'
        )->addColumn(
            'parent_product_id',
            Table::TYPE_INTEGER,
            null,
            ['unsigned' => true, 'nullable' => false],
            'Parent Product ID'
        )->addColumn(
            'child_product_id',
            Table::TYPE_INTEGER,
            null,
            ['unsigned' => true, 'nullable' => false],
            'Child Product ID'
        )->addColumn(
            'is_required',
            Table::TYPE_SMALLINT,
            null,
            ['nullable' => false, 'default' => 0],
            'Is Required'
        )->addForeignKey(
            $installer->getFkName(
                'magefine_custom_bundle',
                'parent_product_id',
                'catalog_product_entity',
                'entity_id'
            ),
            'parent_product_id',
            $installer->getTable('catalog_product_entity'),
            'entity_id',
            Table::ACTION_CASCADE
        )->addForeignKey(
            $installer->getFkName(
                'magefine_custom_bundle',
                'child_product_id',
                'catalog_product_entity',
                'entity_id'
            ),
            'child_product_id',
            $installer->getTable('catalog_product_entity'),
            'entity_id',
            Table::ACTION_CASCADE
        )->setComment(
            'Custom Product Bundle Relationships'
        );

        $installer->getConnection()->createTable($table);
        $installer->endSetup();
    }
}

Step 4: Create the Bundle Model

Now let’s create our main model in Model/Bundle.php:


<?php
namespace Magefine\CustomBundle\Model;

use Magento\Framework\Model\AbstractModel;
use Magefine\CustomBundle\Model\ResourceModel\Bundle as ResourceModel;

class Bundle extends AbstractModel
{
    protected function _construct()
    {
        $this->_init(ResourceModel::class);
    }
}

And its resource model in Model/ResourceModel/Bundle.php:


<?php
namespace Magefine\CustomBundle\Model\ResourceModel;

use Magento\Framework\Model\ResourceModel\Db\AbstractDb;

class Bundle extends AbstractDb
{
    protected function _construct()
    {
        $this->_init('magefine_custom_bundle', 'bundle_id');
    }
}

Step 5: Create the Bundle Repository

For better architecture, we'll implement a repository pattern. Create Model/BundleRepository.php:


<?php
namespace Magefine\CustomBundle\Model;

use Magefine\CustomBundle\Model\ResourceModel\Bundle\CollectionFactory;
use Magento\Framework\Exception\CouldNotSaveException;

class BundleRepository
{
    protected $bundleFactory;
    protected $collectionFactory;

    public function __construct(
        BundleFactory $bundleFactory,
        CollectionFactory $collectionFactory
    ) {
        $this->bundleFactory = $bundleFactory;
        $this->collectionFactory = $collectionFactory;
    }

    public function save($bundle)
    {
        try {
            $bundle->save();
        } catch (\Exception $exception) {
            throw new CouldNotSaveException(__($exception->getMessage()));
        }
        return $bundle;
    }

    public function getBundlesByProductId($productId)
    {
        $collection = $this->collectionFactory->create();
        $collection->addFieldToFilter('parent_product_id', $productId);
        return $collection;
    }
}

Step 6: Frontend Implementation

Let’s create a block to display our bundle options. Create Block/Product/View/Bundle.php:


<?php
namespace Magefine\CustomBundle\Block\Product\View;

use Magento\Catalog\Block\Product\Context;
use Magefine\CustomBundle\Model\BundleRepository;

class Bundle extends \Magento\Catalog\Block\Product\View\AbstractView
{
    protected $bundleRepository;

    public function __construct(
        Context $context,
        \Magento\Framework\Stdlib\ArrayUtils $arrayUtils,
        BundleRepository $bundleRepository,
        array $data = []
    ) {
        $this->bundleRepository = $bundleRepository;
        parent::__construct($context, $arrayUtils, $data);
    }

    public function getBundleOptions()
    {
        $product = $this->getProduct();
        return $this->bundleRepository->getBundlesByProductId($product->getId());
    }
}

Now create the template at view/frontend/templates/product/view/bundle.phtml:


<?php $bundleOptions = $block->getBundleOptions(); ?>
<?php if ($bundleOptions->count()): ?>
<div class="custom-bundle-options">
    <h3></h3>
    <div class="bundle-items">
        <?php foreach ($bundleOptions as $option): ?>
            <?php $childProduct = $block->getProductById($option->getChildProductId()); ?>
            <div class="bundle-item">
                <input type="checkbox" name="bundle_items[]" value="<?= $childProduct->getId() ?>">
                <?= $childProduct->getName() ?> - <?= $block->getPriceHtml($childProduct) ?>
            </div>
        <?php endforeach; ?>
    </div>
</div>
<?php endif; ?>

Step 7: Add to Cart Handling

We need to handle the bundle items when added to cart. Create Controller/Cart/Add.php:


<?php
namespace Magefine\CustomBundle\Controller\Cart;

use Magento\Checkout\Model\Cart;
use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\Controller\Result\Redirect;
use Magento\Catalog\Api\ProductRepositoryInterface;

class Add extends Action
{
    protected $cart;
    protected $productRepository;

    public function __construct(
        Context $context,
        Cart $cart,
        ProductRepositoryInterface $productRepository
    ) {
        $this->cart = $cart;
        $this->productRepository = $productRepository;
        parent::__construct($context);
    }

    public function execute()
    {
        $params = $this->getRequest()->getParams();
        try {
            if (isset($params['bundle_items']) && is_array($params['bundle_items'])) {
                $bundleItems = [];
                foreach ($params['bundle_items'] as $childProductId) {
                    $childProduct = $this->productRepository->getById($childProductId);
                    $bundleItems[] = [
                        'product' => $childProductId,
                        'qty' => 1,
                        'price' => $childProduct->getPrice()
                    ];
                }
                $params['bundle_items'] = $bundleItems;
            }

            $product = $this->productRepository->getById($params['product']);
            unset($params['product']);
            $this->cart->addProduct($product, $params);
            $this->cart->save();

            $this->messageManager->addSuccessMessage(__('Product added to cart successfully.'));
        } catch (\Exception $e) {
            $this->messageManager->addErrorMessage(__('Cannot add item to cart.'));
        }

        /** @var Redirect $resultRedirect */
        $resultRedirect = $this->resultRedirectFactory->create();
        return $resultRedirect->setPath('checkout/cart');
    }
}

Step 8: Configure DI and Layouts

Update etc/di.xml:


<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <preference for="Magefine\CustomBundle\Model\BundleRepositoryInterface" type="Magefine\CustomBundle\Model\BundleRepository"/>
    
    <type name="Magento\Catalog\Block\Product\View">
        <plugin name="custom_bundle_product_view" type="Magefine\CustomBundle\Plugin\ProductViewPlugin"/>
    </type>
</config>

Create the layout file at view/frontend/layout/catalog_product_view.xml:


<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="product.info.form.content">
            <block class="Magefine\CustomBundle\Block\Product\View\Bundle" name="custom.bundle.options" template="Magefine_CustomBundle::product/view/bundle.phtml"/>
        </referenceContainer>
    </body>
</page>

Step 9: Admin Interface (Optional)

To make this complete, you'd want to create an admin interface to manage bundles. This would involve:

  1. Creating a new product attribute for "Is Bundle"
  2. Adding a custom tab in the product edit page
  3. Creating a grid to manage bundle items
  4. Implementing save handlers

Testing Your Module

After setting up:

  1. Run php bin/magento setup:upgrade
  2. Clear cache with php bin/magento cache:clean
  3. Create some test products and mark one as a bundle parent
  4. Add some child products to your bundle
  5. Test the frontend display and add to cart functionality

Final Thoughts

Building a custom bundle module gives you complete control over the bundling functionality in your Magento 2 store. While this example covers the basics, you could extend it with features like:

  • Dynamic pricing rules (discounts for bundles)
  • Conditional products (show certain items only if others are selected)
  • Quantity-based pricing
  • Advanced bundle types (e.g., "build your own" kits)

Remember to always test thoroughly before deploying to production. Happy coding!

If you need a ready-made solution with more advanced features, check out our Magento 2 extensions for professionally developed bundle modules.