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>= __('Bundle Options') ?></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:
- Creating a new product attribute for "Is Bundle"
- Adding a custom tab in the product edit page
- Creating a grid to manage bundle items
- Implementing save handlers
Testing Your Module
After setting up:
- Run
php bin/magento setup:upgrade
- Clear cache with
php bin/magento cache:clean
- Create some test products and mark one as a bundle parent
- Add some child products to your bundle
- 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.