In this post, I'll give instructions on how to create and save the product custom selection image step-by-step. For the purposes of this tutorial `Magetu` is the name of the vendor associated with a module named `BundleSelectionImage`. 1. Create registration.php: File path: `app/code/Magetu/BundleSelectionImage/registration.php` ``` <?php use Magento\Framework\Component\ComponentRegistrar; ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Magetu_BundleSelectionImage', __DIR__); ``` 2. Create module.xml: File path: `app/code/Magetu/BundleSelectionImage/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="Magetu_BundleSelectionImage"> <sequence> <module name="Magento_Bundle"/> <module name="Magento_Catalog"/> </sequence> </module> </config> ``` 3. Create db_schema.xml to add a new column for bundle selection image in `catalog_product_bundle_selection` table. File path: `app/code/Magetu/BundleSelectionImage/etc/db_schema.xml` ``` <?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> ``` 4. Create the di.xml file to: - define a modifier to modify bundle selection structure to add the selection image field - config the Upload class - override `Magento\Bundle\Model\LinkManagement` class to save the bundle selection image value File path: `app/code/Magetu/BundleSelectionImage/etc/adminhtml/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"> <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">Magetu\BundleSelectionImage\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="Magetu\BundleSelectionImage\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="Magetu\BundleSelectionImage\Controller\Adminhtml\Product\Image\Upload"> <arguments> <argument name="imageUploader" xsi:type="object">Magetu\BundleSelectionImage\ImageUpload</argument> </arguments> </type> <preference for="Magento\Bundle\Model\LinkManagement" type="Magetu\BundleSelectionImage\Model\LinkManagement"/> <type name="Magetu\BundleSelectionImage\Model\LinkManagement"> <arguments> <argument name="imageUploader" xsi:type="object">Magetu\BundleSelectionImage\ImageUpload</argument> </arguments> </type> </config> ``` 5. Create routes.xml to use the upload controller. File path: `app/code/Magetu/BundleSelectionImage/etc/adminhtml/routes.xml` ``` <?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="Magetu_BundleSelectionImage" before="Magento_Backend"/> </route> </router> </config> ``` 6. Create modifier class which defined in `di.xml` in the step 4 to modify bundle selection structure to add the selection image field: File path: `app/code/Magetu/BundleSelectionImage/Ui/DataProvider/Product/Form/Modifier/SelectionImage.php` ``` <?php namespace Magetu\BundleSelectionImage\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) { $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' => 'Magetu_BundleSelectionImage/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) ], ], ], ]; } } ``` 7. Create image uploader html file to upload the selection image file: File path: `app/code/Magetu/BundleSelectionImage/view/adminhtml/web/template/form/element/uploader/image.html` ``` <!-- /** * Custom template for upload image file in bundle selection, * based on Magento_Ui::form/element/uploader/image.html * * NOTE: the variable name of the upload file input name should not be declared as 'inputName' * to allow the upload controller get the file */ --> <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> ``` 8. Create the uploader controller to handle upload the selection image file: File path: `app/code/Magetu/BundleSelectionImage/Controller/Adminhtml/Product/Image/Upload.php` ``` <?php namespace Magetu\BundleSelectionImage\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); } } ``` 9. Create LinkManagement model class to override `Magento\Bundle\Model\LinkManagement` class to save the bundle selection image value in the database: File path: `app/code/Magetu/BundleSelectionImage/Model/LinkManagement.php` **Due to Magento 2.4.3 replaced the code method to map product link to bundle selection model, the content of our LinkManagement model class should be different**: - For Magento version >= 2.4.3 ``` <?php namespace Magetu\BundleSelectionImage\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\App\Filesystem\DirectoryList; use Magento\Framework\EntityManager\MetadataPool; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Framework\Exception\FileSystemException; use Magento\Framework\Exception\InputException; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\Filesystem; use Magento\Framework\Filesystem\Directory\WriteInterface; 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 * * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) */ 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 * * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ 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 FileSystemException * @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()); // Start customize $selectionImage = $this->getSelectionImage($parentProduct, $productLink->getSelectionImage()); $selectionModel->setSelectionImage($selectionImage); // End customize return $selectionModel; } /** * Move selection image file and get selection image path * @param $parentProduct * @param $selectionImageData * @return string|null * @throws FileSystemException */ private function getSelectionImage($parentProduct, $selectionImageData) { $selectionImage = null; if ($selectionImageData !== null) { $baseMediaDir = $parentProduct->getStore()->getBaseMediaDir(); $selectionImageName = $selectionImageData[0]['name']; if (isset($selectionImageData[0]['tmp_name'])) { // Save new uploaded selection image try { $newImgRelativePath = $this->imageUploader->moveFileFromTmp( $selectionImageName, true ); $selectionImage = $baseMediaDir . '/' . $newImgRelativePath; } catch (\Exception $e) { $message = __('Saving selection image: ') . $e->getMessage(); $this->messageManager->addErrorMessage($message); } } else { // Save existing selection image /** * It was converted in Modifier for rendering in the admin interface: * Magetu\BundleSelectionImage\Ui\DataProvider\Product\Form\Modifier\SelectionImage::modifyData */ $selectionImage = $baseMediaDir . '/' . $this->imageUploader->getBasePath() . '/' . $selectionImageName; } } return $selectionImage; } } ``` Full module directory structure --- The module is now complete. Your module’s directory structure under `app/code` should look like the following: Magetu |-- BundleSelectionImage | |-- Controller | |-- Adminhtml | |-- Product | |-- Image | |-- Upload.php |-- etc | |-- adminhtml | | |-- di.xml | | |-- routes.xml | |-- db_schema.xml | |-- module.xml |-- Model | |-- LinkManagement.php |-- Ui | |-- DataProvider | |-- Product | |-- Form | |-- Modifier | |-- SelectionImage.php |-- view | |-- adminhtml | |-- web | |-- template | |-- form | |-- element | |-- uploader | |-- image.html |-- registration.php Installing the module --- Run the following commands to register the module, upgrade database, compile code and deploy static content: ``` bin/magento setup:upgrade bin/magento setup:di:compile bin/magento setup:static-content:deploy -f ``` [![enter image description here][1]][1] [1]: https://i.sstatic.net/eotWH.png