Hello Magento Friends,
In today’s Magento 2 solution, I am going to show How to Add a Custom Image Field in Bundle Product Selection in Magento 2.

Bundle products are an excellent feature in Magento 2, allowing customers to create customizable product combinations.
However, you may encounter situations where you need to add additional fields to the bundle product selection, such as a custom image field. In this blog, we’ll walk you through the steps to add a custom image field in the bundle product selection.
Steps to Add a Custom Image Field in Bundle Product Selection for Magento 2:
Step 1: First, we need to create an “db_schema.xml” file inside our extension at the following path.
app/code/Vendor/Extension/etc/db_schema.xml
Then add the code as follows
<?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_selection">
<column xsi:type="varchar" name="selection_image" nullable="true" length="255" comment="Selection Image"/>
</table>
</schema>
Step 2: After that, we need to create an “di.xml” file inside our extension at the following path.
app/code/Vendor/Extension/etc/adminhtml/di.xml
Now add the following code
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<virtualType name="Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\Pool">
<arguments>
<argument name="modifiers" xsi:type="array">
<item name="bundle_selection_image" xsi:type="array">
<item name="class" xsi:type="string">Vendor\Extension\Ui\DataProvider\Product\Form\Modifier\SelectionImage</item>
<item name="sortOrder" xsi:type="number">181</item><!-- run after core bundle modifier -->
</item>
</argument>
</arguments>
</virtualType>
<virtualType name="Vendor\Extension\ImageUpload" type="Magento\Catalog\Model\ImageUploader">
<arguments>
<argument name="baseTmpPath" xsi:type="string">catalog/tmp/product/selection_images</argument>
<argument name="basePath" xsi:type="string">catalog/product/selection_images</argument>
<argument name="allowedExtensions" xsi:type="array">
<item name="jpg" xsi:type="string">jpg</item>
<item name="jpeg" xsi:type="string">jpeg</item>
<item name="gif" xsi:type="string">gif</item>
<item name="png" xsi:type="string">png</item>
<item name="svg" xsi:type="string">svg</item>
<item name="svgz" xsi:type="string">svgz</item>
<item name="webp" xsi:type="string">webp</item>
<item name="avif" xsi:type="string">avif</item>
<item name="avifs" xsi:type="string">avifs</item>
</argument>
<argument name="allowedMimeTypes" xsi:type="array">
<item name="jpg" xsi:type="string">image/jpg</item>
<item name="jpeg" xsi:type="string">image/jpeg</item>
<item name="gif" xsi:type="string">image/gif</item>
<item name="png" xsi:type="string">image/png</item>
<item name="svg" xsi:type="string">image/svg+xml</item>
<item name="svgz" xsi:type="string">image/svg+xml</item>
<item name="webp" xsi:type="string">image/webp</item>
<item name="avif" xsi:type="string">image/avif</item>
<item name="avifs" xsi:type="string">image/avif-sequence</item>
</argument>
</arguments>
</virtualType>
<type name="Vendor\Extension\Controller\Adminhtml\Product\Image\Upload">
<arguments>
<argument name="imageUploader" xsi:type="object">Vendor\Extension\ImageUpload</argument>
</arguments>
</type>
<preference for="Magento\Bundle\Model\LinkManagement" type="Vendor\Extension\Model\LinkManagement"/>
<type name="Vendor\Extension\Model\LinkManagement">
<arguments>
<argument name="imageUploader" xsi:type="object">Vendor\Extension\ImageUpload</argument>
</arguments>
</type>
</config>
Step 3: Next, we need to create an “routes.xml” file inside our extension at the following path.
app/code/Vendor/Extension/etc/adminhtml/routes.xml
Now add the code as follows
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
<router id="admin">
<route id="bundleselectionimage" frontName="bundleselectionimage">
<module name="Vendor_Extension" before="Magento_Backend"/>
</route>
</router>
</config>
Step 4: Thenafter, we need to create a “SelectionImage.php” file inside our extension at the following path.
app/code/Vendor/Extension/Ui/DataProvider/Product/Form/Modifier/SelectionImage.php
Then add the below-mentioned code
<?php
declare(strict_types=1);
namespace Vendor\Extension\Ui\DataProvider\Product\Form\Modifier;
use Magento\Bundle\Model\Product\Type;
use Magento\Bundle\Model\ResourceModel\Selection as SelectionResource;
use Magento\Bundle\Model\SelectionFactory;
use Magento\Bundle\Ui\DataProvider\Product\Form\Modifier\BundlePanel;
use Magento\Catalog\Model\Category\FileInfo;
use Magento\Catalog\Model\Locator\LocatorInterface;
use Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\AbstractModifier;
use Magento\Framework\UrlInterface;
class SelectionImage extends AbstractModifier
{
public const FIELD_IMAGE = 'selection_image';
/**
* @var LocatorInterface
*/
private $locator;
/**
* @var UrlInterface
*/
private $urlBuilder;
/**
* @var FileInfo
*/
private $fileInfo;
/**
* @var SelectionFactory
*/
private $selectionFactory;
/**
* @var SelectionResource
*/
private $selectionResource;
public function __construct(
LocatorInterface $locator,
UrlInterface $urlBuilder,
FileInfo $fileInfo,
SelectionFactory $selectionFactory,
SelectionResource $selectionResource
) {
$this->locator = $locator;
$this->urlBuilder = $urlBuilder;
$this->fileInfo = $fileInfo;
$this->selectionFactory = $selectionFactory;
$this->selectionResource = $selectionResource;
}
/**
* {@inheritdoc}
*
* Converts selection image data to acceptable for rendering format
* Display selection image
*/
public function modifyData(array $data)
{
$product = $this->locator->getProduct();
$modelId = $product->getId();
$isBundleProduct = $product->getTypeId() === Type::TYPE_CODE;
if ($isBundleProduct && $modelId) {
$selectionModel = $this->selectionFactory->create();
foreach ($data[$modelId][BundlePanel::CODE_BUNDLE_OPTIONS][BundlePanel::CODE_BUNDLE_OPTIONS] as &$option) {
foreach ($option['bundle_selections'] as &$selection) {
$this->selectionResource->load($selectionModel, $selection['selection_id']);
$selectionImage = $selectionModel->getData('selection_image');
if ($selectionImage !== null && $this->fileInfo->isExist($selectionImage)) {
$stat = $this->fileInfo->getStat($selectionImage);
$mime = $this->fileInfo->getMimeType($selectionImage);
// phpcs:ignore Magento2.Functions.DiscouragedFunction
$imageUrl = $product->getStore()->getBaseUrl() . $selectionImage;
$imageRendering = [];
$imageRendering[0]['name'] = basename($selectionImage);
$imageRendering[0]['url'] = $imageUrl;
$imageRendering[0]['size'] = $stat['size'];
$imageRendering[0]['type'] = $mime;
$selectionModel->setData('selection_image', $imageRendering);
}
$selection['selection_image'] = $selectionModel->getData('selection_image');
}
}
}
return $data;
}
/**
* Add selection image field
*
* @param array $meta
* @return array
*/
public function modifyMeta(array $meta) {
if ($this->locator->getProduct()->getTypeId() === Type::TYPE_CODE) {
$groupCode = BundlePanel::CODE_BUNDLE_DATA;
$meta[$groupCode]['children']['bundle_options']['children']['record']['children']
['product_bundle_container']['children']['bundle_selections']
['children']['record']['children'][static::FIELD_IMAGE] = $this->getSelectionImageFieldConfig();
}
return $meta;
}
/**
* Get selection image field config
*
* @return array
*/
private function getSelectionImageFieldConfig()
{
return [
'arguments' => [
'data' => [
'config' => [
'componentType' => 'imageUploader',
'formElement' => 'imageUploader',
'template' => 'Vendor_Extension/form/element/uploader/image',
'fileInputName' => static::FIELD_IMAGE,
'uploaderConfig' => [
'url' => $this->urlBuilder->getUrl(
'bundleselectionimage/product_image/upload'
),
],
'dataScope' => static::FIELD_IMAGE,
'sortOrder' => 129,// Before the last field (action_delete)
],
],
],
];
}
}
Step 5: Next, we need to create an “image.html” file inside our extension at the following path.
app/code/Vendor/Extension/view/adminhtml/web/template/form/element/uploader/image.html
And add the following piece of code
<div class="admin__field" visible="visible" css="$data.additionalClasses">
<div class="admin__field-control" css="'_with-tooltip': $data.tooltip">
<div class="file-uploader image-uploader" data-role="drop-zone" css="_loading: isLoading">
<div class="file-uploader-area">
<input type="file" afterRender="onElementRender" attr="id: uid, name: fileInputName, multiple: isMultipleFiles" disable="disabled" />
<label class="file-uploader-button action-default" attr="for: uid, disabled: disabled" disable="disabled" translate="'Upload'"></label>
<render args="fallbackResetTpl" if="$data.showFallbackReset && $data.isDifferedFromDefault"></render>
<p class="image-upload-requirements">
<span if="$data.maxFileSize">
<span translate="'Maximum file size'"></span>: <text args="formatSize($data.maxFileSize)"></text>.
</span>
<span if="$data.allowedExtensions">
<span translate="'Allowed file types'"></span>: <text args="getAllowedFileExtensionsInCommaDelimitedFormat()"></text>.
</span>
</p>
</div>
<render args="tooltipTpl" if="$data.tooltip"></render>
<div class="admin__field-note" if="$data.notice" attr="id: noticeId">
<span html="notice"></span>
</div>
<label class="admin__field-error" if="error" attr="for: uid" text="error"></label>
<each args="data: value, as: '$file'" render="$parent.getPreviewTmpl($file)"></each>
<div if="!hasData()" class="image image-placeholder" click="triggerImageUpload">
<div class="file-uploader-summary product-image-wrapper">
<div class="file-uploader-spinner image-uploader-spinner"></div>
<p class="image-placeholder-text" translate="'Browse to find or drag image here'"></p>
</div>
</div>
</div>
<render args="$data.service.template" if="$data.hasService()"></render>
</div>
</div>

Step 6: Now, we need to create an “Upload.php” file inside our extension at the following path.
app/code/Vendor/Extension/Controller/Adminhtml/Product/Image/Upload.php
Then include the below code snippet
<?php
declare(strict_types=1);
namespace Vendor\Extension\Controller\Adminhtml\Product\Image;
use Magento\Backend\App\Action as BackendAction;
use Magento\Backend\App\Action\Context;
use Magento\Catalog\Model\ImageUploader;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Controller\ResultInterface;
/**
* The bundle selection image upload controller
*/
class Upload extends BackendAction implements HttpPostActionInterface
{
/**
* Authorization level of a basic admin session
*
* @see _isAllowed()
*/
public const ADMIN_RESOURCE = 'Magento_Catalog::products';
/**
* @var ImageUploader
*/
private $imageUploader;
public function __construct(
Context $context,
ImageUploader $imageUploader
) {
parent::__construct($context);
$this->imageUploader = $imageUploader;
}
/**
* Upload selection image file controller action
*/
public function execute(): ResultInterface
{
$imageId = $this->_request->getParam('param_name', 'selection_image');
try {
$result = $this->imageUploader->saveFileToTmpDir($imageId);
} catch (\Exception $e) {
$result = ['error' => $e->getMessage(), 'errorcode' => $e->getCode()];
}
return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result);
}
}
Step 7: After that, we need to create a “LinkManagement.php” file inside our extension at the following path.
app/code/Vendor/Extension/Model/LinkManagement.php
Now add code as mentioned below
<?php
declare(strict_types=1);
namespace Vendor\Extension\Model;
use Magento\Bundle\Api\Data\LinkInterface;
use Magento\Bundle\Api\Data\LinkInterfaceFactory;
use Magento\Bundle\Model\ResourceModel\Bundle;
use Magento\Bundle\Model\ResourceModel\BundleFactory;
use Magento\Bundle\Model\ResourceModel\Option\CollectionFactory;
use Magento\Bundle\Model\Selection;
use Magento\Bundle\Model\SelectionFactory;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\ImageUploader;
use Magento\Catalog\Model\Product;
use Magento\Framework\Api\DataObjectHelper;
use Magento\Framework\EntityManager\MetadataPool;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\InputException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Message\Manager as MessageManager;
use Magento\Store\Model\StoreManagerInterface;
class LinkManagement extends \Magento\Bundle\Model\LinkManagement
{
/**
* @var StoreManagerInterface
*/
private $storeManager;
/**
* @var MetadataPool
*/
private $metadataPool;
/**
* @var ImageUploader
*/
private $imageUploader;
/**
* @var MessageManager
*/
private $messageManager;
public function __construct(
ProductRepositoryInterface $productRepository,
LinkInterfaceFactory $linkFactory,
SelectionFactory $bundleSelection,
BundleFactory $bundleFactory,
CollectionFactory $optionCollection,
StoreManagerInterface $storeManager,
DataObjectHelper $dataObjectHelper,
MetadataPool $metadataPool,
ImageUploader $imageUploader,
MessageManager $messageManager
) {
parent::__construct(
$productRepository,
$linkFactory,
$bundleSelection,
$bundleFactory,
$optionCollection,
$storeManager,
$dataObjectHelper,
$metadataPool
);
$this->storeManager = $storeManager;
$this->metadataPool = $metadataPool;
$this->imageUploader = $imageUploader;
$this->messageManager = $messageManager;
}
/**
* This method has same content as in the parent class
* Declare it here to allow override mapProductLinkToBundleSelectionModel which is private
*
* @inheritDoc
*/
public function saveChild(
$sku,
LinkInterface $linkedProduct
) {
$product = $this->productRepository->get($sku, true);
if ($product->getTypeId() != Product\Type::TYPE_BUNDLE) {
throw new InputException(
__('The product with the "%1" SKU isn\'t a bundle product.', [$product->getSku()])
);
}
/** @var Product $linkProductModel */
$linkProductModel = $this->productRepository->get($linkedProduct->getSku());
if ($linkProductModel->isComposite()) {
throw new InputException(__('The bundle product can\'t contain another composite product.'));
}
if (!$linkedProduct->getId()) {
throw new InputException(__('The product link needs an ID field entered. Enter and try again.'));
}
/** @var Selection $selectionModel */
$selectionModel = $this->bundleSelection->create();
$selectionModel->load($linkedProduct->getId());
if (!$selectionModel->getId()) {
throw new InputException(
__(
'The product link with the "%1" ID field wasn\'t found. Verify the ID and try again.',
[$linkedProduct->getId()]
)
);
}
$selectionModel = $this->mapProductLinkToBundleSelectionModel(
$selectionModel,
$linkedProduct,
$product,
(int)$linkProductModel->getId()
);
try {
$selectionModel->save();
} catch (\Exception $e) {
throw new CouldNotSaveException(__('Could not save child: "%1"', $e->getMessage()), $e);
}
return true;
}
/**
* This method has same content as in the parent class
* Declare it here to allow override mapProductLinkToBundleSelectionModel which is private
*
* @inheritDoc
*/
public function addChild(
ProductInterface $product,
$optionId,
LinkInterface $linkedProduct
) {
if ($product->getTypeId() != Product\Type::TYPE_BUNDLE) {
throw new InputException(
__('The product with the "%1" SKU isn\'t a bundle product.', $product->getSku())
);
}
$linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField();
$options = $this->optionCollection->create();
$options->setIdFilter($optionId);
$options->setProductLinkFilter($product->getData($linkField));
$existingOption = $options->getFirstItem();
if (!$existingOption->getId()) {
throw new InputException(
__(
'Product with specified sku: "%1" does not contain option: "%2"',
[$product->getSku(), $optionId]
)
);
}
/* @var $resource Bundle */
$resource = $this->bundleFactory->create();
$selections = $resource->getSelectionsData($product->getData($linkField));
/** @var Product $linkProductModel */
$linkProductModel = $this->productRepository->get($linkedProduct->getSku());
if ($linkProductModel->isComposite()) {
throw new InputException(__('The bundle product can\'t contain another composite product.'));
}
if ($selections) {
foreach ($selections as $selection) {
if ($selection['option_id'] == $optionId &&
$selection['product_id'] == $linkProductModel->getEntityId() &&
$selection['parent_product_id'] == $product->getData($linkField)) {
if (!$product->getCopyFromView()) {
throw new CouldNotSaveException(
__(
'Child with specified sku: "%1" already assigned to product: "%2"',
[$linkedProduct->getSku(), $product->getSku()]
)
);
}
return $this->bundleSelection->create()->load($linkProductModel->getEntityId());
}
}
}
$selectionModel = $this->bundleSelection->create();
$selectionModel = $this->mapProductLinkToBundleSelectionModel(
$selectionModel,
$linkedProduct,
$product,
(int)$linkProductModel->getEntityId()
);
$selectionModel->setOptionId($optionId);
try {
$selectionModel->save();
$resource->addProductRelation($product->getData($linkField), $linkProductModel->getEntityId());
} catch (\Exception $e) {
throw new CouldNotSaveException(__('Could not save child: "%1"', $e->getMessage()), $e);
}
return (int)$selectionModel->getId();
}
/**
* Fill selection model with product link data.
* Save selection image value
*
* @param Selection $selectionModel
* @param LinkInterface $productLink
* @param ProductInterface $parentProduct
* @param int $linkedProductId
* @return Selection
* @throws NoSuchEntityException
*/
private function mapProductLinkToBundleSelectionModel(
Selection $selectionModel,
LinkInterface $productLink,
ProductInterface $parentProduct,
int $linkedProductId
): Selection {
$linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField();
$selectionModel->setProductId($linkedProductId);
$selectionModel->setParentProductId($parentProduct->getData($linkField));
if ($productLink->getSelectionId() !== null) {
$selectionModel->setSelectionId($productLink->getSelectionId());
}
if ($productLink->getOptionId() !== null) {
$selectionModel->setOptionId($productLink->getOptionId());
}
if ($productLink->getPosition() !== null) {
$selectionModel->setPosition($productLink->getPosition());
}
if ($productLink->getQty() !== null) {
$selectionModel->setSelectionQty($productLink->getQty());
}
if ($productLink->getPriceType() !== null) {
$selectionModel->setSelectionPriceType($productLink->getPriceType());
}
if ($productLink->getPrice() !== null) {
$selectionModel->setSelectionPriceValue($productLink->getPrice());
}
if ($productLink->getCanChangeQuantity() !== null) {
$selectionModel->setSelectionCanChangeQty($productLink->getCanChangeQuantity());
}
if ($productLink->getIsDefault() !== null) {
$selectionModel->setIsDefault($productLink->getIsDefault());
}
$selectionModel->setWebsiteId((int)$this->storeManager->getStore($parentProduct->getStoreId())->getWebsiteId());
$selectionImageData = $productLink->getSelectionImage();
if ($selectionImageData !== null) {
$selectionImage = $this->getSelectionImage($selectionImageData);
$selectionModel->setSelectionImage($selectionImage);
}
return $selectionModel;
}
/**
* Move uploaded selection image file and get selection image path
*
* @param $selectionImageData
* @return string|null
* @throws NoSuchEntityException
*/
private function getSelectionImage($selectionImageData)
{
$selectionImage = null;
$baseMediaDir = $this->storeManager->getStore()->getBaseMediaDir();
$selectionImageName = $selectionImageData[0]['name'];
if (isset($selectionImageData[0]['tmp_name'])) {
try {
$newImgRelativePath = $this->imageUploader->moveFileFromTmp(
$selectionImageName,
true
);
$selectionImage = $baseMediaDir . '/' . $newImgRelativePath;
} catch (\Exception $e) {
$message = __('Saving selection image: ') . $e->getMessage();
$this->messageManager->addErrorMessage($message);
}
} else {
$selectionImage = $baseMediaDir . '/' . $this->imageUploader->getBasePath() . '/' . $selectionImageName;
}
return $selectionImage;
}
}
Output:
Navigate to the bundle product page and verify that the custom image field is displayed and functional.


Conclusion
Adding a custom image field to bundle product selections in Magento 2 is a straightforward process when done with proper module customization. By following the steps outlined above, you can extend bundle product options to meet specific business requirements.
Related Solutions
- How to Add Additional Column in Invoice PDF for Products Bundle in Magento 2?
- Magento 2: How to Get Parent Product ID from Child Product ID for Bundled Products
- How to Get Child Items of Bundle Products in Magento 2
- How to Get Bundle Product Option Image in Magento 2
If you encounter any issues, feel free to comment below for assistance!