2

In Magento 2.x, core functionality does not allow you to set Custom Options on child products belonging to a Configurable Product. If you attempt to set a custom option on such a child product (which in Magento 2.x is a Virtual Product), it is removed from the parent Configurable Product when you save it.

Are there any known workarounds to this limitation? Any help in writing custom code to support this in Configurable Products would be appreciated. Thanks.

2
  • Just found this limitation too. Just one of the many joys of M2 dev Commented Apr 30, 2020 at 6:54
  • Hi, did you find a solution? Commented Jul 15, 2020 at 9:45

1 Answer 1

1

Magento 2.4.x

Below code is for save child products with a custom option which part of the configurable product.

app/code/VendoreName/ModuleName/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="Magento\Catalog\Model\Product\Option\SaveHandler" type="VendoreName\ModuleName\Model\Product\Option\SaveHandler" />

    <preference for="Magento\ConfigurableProduct\Model\Product\Type\Configurable" type="VendoreName\ModuleName\Model\Product\Type\Configurable" />

</config>

app/code/VendoreName/ModuleName/Model/Product/Option

SaveHandler.php

<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
declare (strict_types = 1);

namespace VendoreName\ModuleName\Model\Product\Option;

use Magento\Catalog\Api\Data\ProductCustomOptionInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductCustomOptionRepositoryInterface as OptionRepository;
use Magento\Catalog\Model\Product\Option;
use Magento\Catalog\Model\ResourceModel\Product\Relation;
use Magento\Framework\App\ObjectManager;
use Magento\Framework\Exception\CouldNotSaveException;

/**
 * SaveHandler for product option
 *
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 */
class SaveHandler extends \Magento\Catalog\Model\Product\Option\SaveHandler
{
    /**
     * @var string[]
     */
    private $compositeProductTypes = ['grouped', 'configurable', 'bundle'];

    /**
     * @var OptionRepository
     */
    protected $optionRepository;

    /**
     * @var Relation
     */
    private $relation;

    /**
     * @param OptionRepository $optionRepository
     * @param Relation|null $relation
     */
    public function __construct(
        OptionRepository $optionRepository,
        ? Relation $relation = null
    ) {
        $this->optionRepository = $optionRepository;
        $this->relation = $relation ?: ObjectManager::getInstance()->get(Relation::class);
        parent::__construct($optionRepository, $relation);
    }

    /**
     * Perform action on relation/extension attribute
     *
     * @param object $entity
     * @param array $arguments
     * @return ProductInterface|object
     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
     */
    public function execute($entity, $arguments = [])
    {
        if ($entity->getOptionsSaved()) {
            return $entity;
        }

        $options = $entity->getOptions();
        $optionIds = [];

        if ($options) {
            $optionIds = array_map(function (Option $option) {
                return $option->getOptionId();
            }, $options);
        }

        /** @var ProductInterface $entity */
        foreach ($this->optionRepository->getProductOptions($entity) as $option) {
            if (!in_array($option->getOptionId(), $optionIds)) {
                $this->optionRepository->delete($option);
            }
        }
        if ($options) {
            $this->processOptionsSaving($options, (bool) $entity->dataHasChangedFor('sku'), $entity);
        }

        return $entity;
    }

    /**
     * Save custom options
     *
     * @param array $options
     * @param bool $hasChangedSku
     * @param ProductInterface $product
     * @return void
     * @throws CouldNotSaveException
     */
    private function processOptionsSaving(array $options, bool $hasChangedSku, ProductInterface $product): void
    {
        $isProductHasRelations = $this->isProductHasRelations($product);
        /** @var ProductCustomOptionInterface $option */
        foreach ($options as $option) {
            // if (!$isProductHasRelations && $option->getIsRequire()) {
            //     $message = 'Required custom options cannot be added to a simple product'
            //         . ' that is a part of a composite product.';
            //     throw new CouldNotSaveException(__($message));
            // }

            if ($hasChangedSku && $option->hasData('product_sku')) {
                $option->setProductSku($product->getSku());
            }
            $this->optionRepository->save($option);
        }
    }

    /**
     * Check if product doesn't belong to composite product
     *
     * @param ProductInterface $product
     * @return bool
     */
    private function isProductHasRelations(ProductInterface $product): bool
    {
        $result = true;
        if (!in_array($product->getId(), $this->compositeProductTypes)
            && $this->relation->getRelationsByChildren([$product->getId()])
        ) {
            $result = false;
        }

        return $result;
    }
}

app/code/VendoreName/ModuleName/Model/Product/Type

Configurable.php

<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */

namespace VendoreName\ModuleName\Model\Product\Type;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\Data\ProductInterfaceFactory;
use Magento\Catalog\Api\ProductAttributeRepositoryInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\Config;
use Magento\Catalog\Model\Product\Gallery\ReadHandler as GalleryReadHandler;
use Magento\ConfigurableProduct\Model\Product\Type\Collection\SalableProcessor;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\App\ObjectManager;
use Magento\Framework\EntityManager\MetadataPool;
use Magento\Framework\File\UploaderFactory;

/**
 * Configurable product type implementation
 *
 * This type builds in product attributes and existing simple products
 *
 * @SuppressWarnings(PHPMD.TooManyFields)
 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 * @SuppressWarnings(PHPMD.CookieAndSessionMisuse)
 * @api
 * @since 100.0.2
 */
class Configurable extends \Magento\ConfigurableProduct\Model\Product\Type\Configurable
{
    /**
     * Product type code
     */
    const TYPE_CODE = 'configurable';

    /**
     * Cache key for Used Product Attribute Ids
     *
     * @var string
     * @since 100.1.0
     */
    protected $usedProductAttributeIds = '_cache_instance_used_product_attribute_ids';

    /**
     * Cache key for Used Product Attributes
     *
     * @var string
     * @since 100.1.0
     */
    protected $usedProductAttributes = '_cache_instance_used_product_attributes';

    /**
     * Cache key for Used Attributes
     *
     * @var string
     */
    protected $_usedAttributes = '_cache_instance_used_attributes';

    /**
     * Cache key for configurable attributes
     *
     * @var string
     */
    protected $_configurableAttributes = '_cache_instance_configurable_attributes';

    /**
     * Cache key for Used product ids
     *
     * @var string
     */
    protected $_usedProductIds = '_cache_instance_product_ids';

    /**
     * Cache key for used products
     *
     * @var string
     */
    protected $_usedProducts = '_cache_instance_products';

    /**
     * Cache key for salable used products
     *
     * @var string
     */
    private $usedSalableProducts = '_cache_instance_salable_products';

    /**
     * Product is composite
     *
     * @var bool
     */
    protected $_isComposite = true;

    /**
     * Product is configurable
     *
     * @var bool
     */
    protected $_canConfigure = true;

    /**
     * Local cache
     *
     * @var array
     * @since 100.4.0
     */
    protected $isSaleableBySku = [];

    /**
     * @var \Magento\Framework\App\Config\ScopeConfigInterface
     */
    protected $_scopeConfig;

    /**
     * Catalog product type configurable
     *
     * @var \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable
     */
    protected $_catalogProductTypeConfigurable;

    /**
     * Attribute collection factory
     *
     * @var
     * \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Attribute\CollectionFactory
     */
    protected $_attributeCollectionFactory;

    /**
     * Product collection factory
     *
     * @var \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Product\CollectionFactory
     */
    protected $_productCollectionFactory;

    /**
     * Configurable attribute factory
     *
     * @var \Magento\ConfigurableProduct\Model\Product\Type\Configurable\AttributeFactory
     * @since 100.1.0
     */
    protected $configurableAttributeFactory;

    /**
     * Eav attribute factory
     *
     * @var \Magento\Catalog\Model\ResourceModel\Eav\AttributeFactory
     */
    protected $_eavAttributeFactory;

    /**
     * Type configurable factory
     *
     * @var \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\ConfigurableFactory
     * @since 100.1.0
     */
    protected $typeConfigurableFactory;

    /**
     * @var \Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface
     */
    protected $extensionAttributesJoinProcessor;

    /**
     * @var \Magento\Framework\Cache\FrontendInterface
     */
    private $cache;

    /**
     * @var MetadataPool
     */
    private $metadataPool;

    /**
     * @var GalleryReadHandler
     */
    private $productGalleryReadHandler;

    /**
     * @var Config
     */
    private $catalogConfig;

    /**
     * @var \Magento\Customer\Model\Session
     */
    private $customerSession;

    /**
     * Product factory
     *
     * @var ProductInterfaceFactory
     */
    private $productFactory;

    /**
     * Collection salable processor
     *
     * @var SalableProcessor
     */
    private $salableProcessor;

    /**
     * @var ProductAttributeRepositoryInterface|null
     */
    private $productAttributeRepository;

    /**
     * @var SearchCriteriaBuilder|null
     */
    private $searchCriteriaBuilder;

    /**
     * @codingStandardsIgnoreStart/End
     * @param \Magento\Catalog\Model\Product\Option $catalogProductOption
     * @param \Magento\Eav\Model\Config $eavConfig
     * @param \Magento\Catalog\Model\Product\Type $catalogProductType
     * @param \Magento\Framework\Event\ManagerInterface $eventManager
     * @param \Magento\MediaStorage\Helper\File\Storage\Database $fileStorageDb
     * @param \Magento\Framework\Filesystem $filesystem
     * @param \Magento\Framework\Registry $coreRegistry
     * @param \Psr\Log\LoggerInterface $logger
     * @param ProductRepositoryInterface $productRepository
     * @param \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\ConfigurableFactory $typeConfigurableFactory
     * @param \Magento\Catalog\Model\ResourceModel\Eav\AttributeFactory $eavAttributeFactory
     * @param \Magento\ConfigurableProduct\Model\Product\Type\Configurable\AttributeFactory $configurableAttributeFactory
     * @param \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Product\CollectionFactory $productCollectionFactory
     * @param \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Attribute\CollectionFactory $attributeCollectionFactory
     * @param \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable $catalogProductTypeConfigurable
     * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig
     * @param \Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface $extensionAttributesJoinProcessor
     * @param \Magento\Framework\Cache\FrontendInterface|null $cache
     * @param \Magento\Customer\Model\Session|null $customerSession
     * @param \Magento\Framework\Serialize\Serializer\Json|null $serializer
     * @param ProductInterfaceFactory|null $productFactory
     * @param SalableProcessor|null $salableProcessor
     * @param ProductAttributeRepositoryInterface|null $productAttributeRepository
     * @param SearchCriteriaBuilder|null $searchCriteriaBuilder
     * @param UploaderFactory|null $uploaderFactory
     * @SuppressWarnings(PHPMD.ExcessiveParameterList)
     */
    public function __construct(
        \Magento\Catalog\Model\Product\Option $catalogProductOption,
        \Magento\Eav\Model\Config $eavConfig,
        \Magento\Catalog\Model\Product\Type $catalogProductType,
        \Magento\Framework\Event\ManagerInterface $eventManager,
        \Magento\MediaStorage\Helper\File\Storage\Database $fileStorageDb,
        \Magento\Framework\Filesystem $filesystem,
        \Magento\Framework\Registry $coreRegistry,
        \Psr\Log\LoggerInterface $logger,
        ProductRepositoryInterface $productRepository,
        \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\ConfigurableFactory $typeConfigurableFactory,
        \Magento\Catalog\Model\ResourceModel\Eav\AttributeFactory $eavAttributeFactory,
        \Magento\ConfigurableProduct\Model\Product\Type\Configurable\AttributeFactory $configurableAttributeFactory,
        \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Product\CollectionFactory $productCollectionFactory,
        \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Attribute\CollectionFactory $attributeCollectionFactory,
        \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable $catalogProductTypeConfigurable,
        \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
        \Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface $extensionAttributesJoinProcessor,
        \Magento\Framework\Cache\FrontendInterface $cache = null,
        \Magento\Customer\Model\Session $customerSession = null,
        \Magento\Framework\Serialize\Serializer\Json $serializer = null,
        ProductInterfaceFactory $productFactory = null,
        SalableProcessor $salableProcessor = null,
        ProductAttributeRepositoryInterface $productAttributeRepository = null,
        SearchCriteriaBuilder $searchCriteriaBuilder = null,
        UploaderFactory $uploaderFactory = null
    ) {
        $this->typeConfigurableFactory = $typeConfigurableFactory;
        $this->_eavAttributeFactory = $eavAttributeFactory;
        $this->configurableAttributeFactory = $configurableAttributeFactory;
        $this->_productCollectionFactory = $productCollectionFactory;
        $this->_attributeCollectionFactory = $attributeCollectionFactory;
        $this->_catalogProductTypeConfigurable = $catalogProductTypeConfigurable;
        $this->_scopeConfig = $scopeConfig;
        $this->extensionAttributesJoinProcessor = $extensionAttributesJoinProcessor;
        $this->cache = $cache;
        $this->customerSession = $customerSession;
        $this->productFactory = $productFactory ?: ObjectManager::getInstance()
            ->get(ProductInterfaceFactory::class);
        $this->salableProcessor = $salableProcessor ?: ObjectManager::getInstance()->get(SalableProcessor::class);
        $this->productAttributeRepository = $productAttributeRepository ?:
        ObjectManager::getInstance()->get(ProductAttributeRepositoryInterface::class);
        $this->searchCriteriaBuilder = $searchCriteriaBuilder ?:
        ObjectManager::getInstance()->get(SearchCriteriaBuilder::class);
        parent::__construct(
            $catalogProductOption,
            $eavConfig,
            $catalogProductType,
            $eventManager,
            $fileStorageDb,
            $filesystem,
            $coreRegistry,
            $logger,
            $productRepository,
            $typeConfigurableFactory,
            $eavAttributeFactory,
            $configurableAttributeFactory,
            $productCollectionFactory,
            $attributeCollectionFactory,
            $catalogProductTypeConfigurable,
            $scopeConfig,
            $extensionAttributesJoinProcessor,
            $cache,
            $customerSession,
            $serializer,
            $productFactory,
            $salableProcessor,
            $productAttributeRepository,
            $searchCriteriaBuilder,
            $uploaderFactory
        );
    }

    /**
     * Load collection on sub-products for specified configurable product
     *
     * Load collection of sub-products, apply result to specified configurable product and store result to cache
     * Please note $salableOnly parameter is used for backwards compatibility because of deprecated method
     * getSalableUsedProducts
     * Number of loaded sub-products depends on $salableOnly parameter
     * $salableOnly = true - result array contains only salable sub-products
     * $salableOnly = false - result array contains all sub-products
     * $cacheKey - allow store result data in different cache records
     *
     * @param \Magento\Catalog\Model\Product $product
     * @param string $cacheKey
     * @param bool $salableOnly
     * @return ProductInterface[]
     */
    public function loadUsedProducts(\Magento\Catalog\Model\Product $product, $cacheKey, $salableOnly = false)
    {
        $dataFieldName = $salableOnly ? $this->usedSalableProducts : $this->_usedProducts;
        if (!$product->hasData($dataFieldName)) {
            $usedProducts = $this->readUsedProductsCacheData($cacheKey);
            if ($usedProducts === null) {
                $collection = $this->getConfiguredUsedProductCollection($product, false);
                if ($salableOnly) {
                    $collection = $this->salableProcessor->process($collection);
                }
                $usedProducts = array_values($collection->getItems());
                $this->saveUsedProductsCacheData($product, $usedProducts, $cacheKey);
            }
            $product->setData($dataFieldName, $usedProducts);
        }

        return $product->getData($dataFieldName);
    }

    /**
     * Read used products data from cache
     *
     * Looking for cache record stored under provided $cacheKey
     * In case data exists turns it into array of products
     *
     * @param string $cacheKey
     * @return ProductInterface[]|null
     */
    public function readUsedProductsCacheData($cacheKey)
    {
        $usedProducts = null;
        $data = $this->getCache()->load($cacheKey);
        if (!$data) {
            return $usedProducts;
        }
        $data = $this->serializer->unserialize($data);
        if (!empty($data)) {
            $usedProducts = [];
            foreach ($data as $item) {
                $productItem = $this->productFactory->create();
                $productItem->setData($item);
                $usedProducts[] = $productItem;
            }
        }

        return $usedProducts;
    }

    /**
     * Save $subProducts to cache record identified with provided $cacheKey
     *
     * Cached data will be tagged with combined list of product tags and data specific tags i.e. 'price' etc.
     *
     * @param \Magento\Catalog\Model\Product $product
     * @param ProductInterface[] $subProducts
     * @param string $cacheKey
     * @return bool
     */
    public function saveUsedProductsCacheData(\Magento\Catalog\Model\Product $product, array $subProducts, $cacheKey)
    {
        $metadata = $this->getMetadataPool()->getMetadata(ProductInterface::class);
        return $this->getCache()->save(
            $this->serializer->serialize(array_map(
                function ($item) {
                    return $item->getData();
                },
                $subProducts
            )),
            $cacheKey,
            array_merge(
                $product->getIdentities(),
                [
                    \Magento\Catalog\Model\Category::CACHE_TAG,
                    \Magento\Catalog\Model\Product::CACHE_TAG,
                    'price',
                    self::TYPE_CODE . '_' . $product->getData($metadata->getLinkField()),
                ]
            )
        );
    }

    /**
     * Returns array of sub-products for specified configurable product
     * Result array contains all children for specified configurable product
     *
     * @param \Magento\Catalog\Model\Product $product
     * @param array $requiredAttributeIds Attributes to include in the select; one-dimensional array
     * @return ProductInterface[]
     */
    public function getUsedProducts($product, $requiredAttributeIds = null)
    {
        if (!$product->hasData($this->_usedProducts)) {
            $collection = $this->getConfiguredUsedProductCollection($product, false, $requiredAttributeIds);
            $usedProducts = array_values($collection->getItems());
            $product->setData($this->_usedProducts, $usedProducts);
        }

        return $product->getData($this->_usedProducts);
    }

    /**
     * Prepare collection for retrieving sub-products of specified configurable product
     * Retrieve related products collection with additional configuration
     *
     * @param \Magento\Catalog\Model\Product $product
     * @param bool $skipStockFilter
     * @param array $requiredAttributeIds Attributes to include in the select
     * @return \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Product\Collection
     * @throws \Magento\Framework\Exception\LocalizedException
     */
    public function getConfiguredUsedProductCollection(
        \Magento\Catalog\Model\Product $product,
        $skipStockFilter = true,
        $requiredAttributeIds = null
    ) {
        $collection = $this->getUsedProductCollection($product);

        if ($skipStockFilter) {
            $collection->setFlag('has_stock_status_filter', true);
        }

        $attributesForSelect = $this->getAttributesForCollection($product);
        if ($requiredAttributeIds) {
            $this->searchCriteriaBuilder->addFilter('attribute_id', $requiredAttributeIds, 'in');
            $requiredAttributes = $this->productAttributeRepository
                ->getList($this->searchCriteriaBuilder->create())->getItems();
            $requiredAttributeCodes = [];
            foreach ($requiredAttributes as $requiredAttribute) {
                $requiredAttributeCodes[] = $requiredAttribute->getAttributeCode();
            }
            $attributesForSelect = array_unique(array_merge($attributesForSelect, $requiredAttributeCodes));
        }
        $collection
            ->addAttributeToSelect($attributesForSelect)
            ->setStoreId($product->getStoreId());

        $collection->addMediaGalleryData();
        $collection->addTierPriceData();

        return $collection;
    }

    /**
     * @return array
     */
    public function getAttributesForCollection(\Magento\Catalog\Model\Product $product)
    {
        $productAttributes = $this->getCatalogConfig()->getProductAttributes();

        $requiredAttributes = [
            'name',
            'price',
            'weight',
            'image',
            'thumbnail',
            'status',
            'visibility',
            'media_gallery',
        ];

        $usedAttributes = array_map(
            function ($attr) {
                return $attr->getAttributeCode();
            },
            $this->getUsedProductAttributes($product)
        );

        return array_unique(array_merge($productAttributes, $requiredAttributes, $usedAttributes));
    }

    /**
     * Get Config instance
     * @return Config
     * @deprecated 100.1.0
     */
    public function getCatalogConfig()
    {
        if (!$this->catalogConfig) {
            $this->catalogConfig = ObjectManager::getInstance()->get(Config::class);
        }
        return $this->catalogConfig;
    }

}

For more check below links

https://github.com/magento/magento2/issues/30492

Associate Simple Products with Custom Options to Configurable Product

Note: After added above files child product are not remove from the parent configurable product when you save it's child product with custom option.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.