Skip to content

Instantly share code, notes, and snippets.

@hryvinskyi
Last active July 17, 2026 10:12
Show Gist options
  • Select an option

  • Save hryvinskyi/5557bd641d8f61005d68202c66cf228e to your computer and use it in GitHub Desktop.

Select an option

Save hryvinskyi/5557bd641d8f61005d68202c66cf228e to your computer and use it in GitHub Desktop.
gist-magento-module-quote-246.patch

Fix File Custom Option Processing via REST/SOAP API

Backport of mage-os/mageos-magento2#210 for Magento 2.

Problem

File-type custom options cannot be submitted through the REST/SOAP API when adding products to cart. The CustomOptionProcessor does not handle file_info data from API requests, and there is no validation to prevent file uploads on non-file option types.

What the Patch Does

  • magento/framework -- Adds ImageContentUploaderInterface with saveToTmpDir() / moveFromTmpDir() methods. Refactors ImageProcessor to implement it.
  • magento/module-catalog -- Updates CustomOptionProcessor to detect file-type options and process image content via a new ImageContentProcessor class.
  • magento/module-quote -- Adds CartItemValidatorInterface chain that validates cart items before save. Prevents file uploads on non-file option types via REST/SOAP.
  • magento/magento2-base -- Registers the ImageContentUploaderInterface preference in global DI.

Compatibility

Magento Version Patch Files Status
2.4.8-p4 magento-framework.patch, magento-magento2-base.patch, magento-module-catalog.patch, magento-module-quote.patch Tested
2.4.7-p7 magento-framework-246.patch, magento-magento2-base-246.patch, magento-module-catalog-246.patch, magento-module-quote-246.patch Tested
2.4.7-p8 magento-framework-246.patch, magento-magento2-base-246.patch, magento-module-catalog-246.patch, magento-module-quote-246.patch Tested
2.4.7-p9 magento-framework-246.patch, magento-magento2-base-246.patch, magento-module-catalog-246.patch, magento-module-quote-246.patch Tested
2.4.6-p14 magento-framework-246.patch, magento-magento2-base-246.patch, magento-module-catalog-246.patch, magento-module-quote-246.patch Tested
2.4.5-p14 magento-framework-246.patch, magento-magento2-base-246.patch, magento-module-catalog-246.patch, magento-module-quote-246.patch Tested

Key Differences Between Versions

The 2.4.5/2.4.6/2.4.7 patches adapt to the older codebase:

  • Quote Repository.php -- No QuoteMutexInterface in constructor (changed in 2.4.8+)
  • Quote etc/webapi_rest/di.xml -- No ErrorRedirectProcessor plugin
  • Catalog ImageContentProcessor -- Uses new ValidatorChain() directly instead of ValidatorChainFactory (not available before 2.4.8)

Prerequisites

  • cweagans/composer-patches installed

Installation

Step 1: Download Patch Files

Choose the correct set of patch files for your Magento version.

For Magento 2.4.8-p4:

mkdir -p patches/magento/{framework,module-catalog,module-quote,magento2-base}

curl -o patches/magento/framework/fix-file-custom-option-api.patch \
  "https://gist.githubusercontent.com/hryvinskyi/5557bd641d8f61005d68202c66cf228e/raw/magento-framework.patch"

curl -o patches/magento/module-catalog/fix-file-custom-option-api.patch \
  "https://gist.githubusercontent.com/hryvinskyi/5557bd641d8f61005d68202c66cf228e/raw/magento-module-catalog.patch"

curl -o patches/magento/module-quote/fix-file-custom-option-api.patch \
  "https://gist.githubusercontent.com/hryvinskyi/5557bd641d8f61005d68202c66cf228e/raw/magento-module-quote.patch"

curl -o patches/magento/magento2-base/fix-file-custom-option-api.patch \
  "https://gist.githubusercontent.com/hryvinskyi/5557bd641d8f61005d68202c66cf228e/raw/magento-magento2-base.patch"

For Magento 2.4.5-p14 / 2.4.6-p14 / 2.4.7-p7 / 2.4.7-p8 / 2.4.7-p9:

mkdir -p patches/magento/{framework,module-catalog,module-quote,magento2-base}

curl -o patches/magento/framework/fix-file-custom-option-api.patch \
  "https://gist.githubusercontent.com/hryvinskyi/5557bd641d8f61005d68202c66cf228e/raw/magento-framework-246.patch"

curl -o patches/magento/module-catalog/fix-file-custom-option-api.patch \
  "https://gist.githubusercontent.com/hryvinskyi/5557bd641d8f61005d68202c66cf228e/raw/magento-module-catalog-246.patch"

curl -o patches/magento/module-quote/fix-file-custom-option-api.patch \
  "https://gist.githubusercontent.com/hryvinskyi/5557bd641d8f61005d68202c66cf228e/raw/magento-module-quote-246.patch"

curl -o patches/magento/magento2-base/fix-file-custom-option-api.patch \
  "https://gist.githubusercontent.com/hryvinskyi/5557bd641d8f61005d68202c66cf228e/raw/magento-magento2-base-246.patch"

Step 2: Add to composer.json

Add the following to the extra.patches section:

{
    "extra": {
        "patches": {
            "magento/framework": {
                "Fix file custom option API processing (mage-os#210)": "patches/magento/framework/fix-file-custom-option-api.patch"
            },
            "magento/module-catalog": {
                "Fix file custom option API processing (mage-os#210)": "patches/magento/module-catalog/fix-file-custom-option-api.patch"
            },
            "magento/module-quote": {
                "Fix file custom option API processing (mage-os#210)": "patches/magento/module-quote/fix-file-custom-option-api.patch"
            },
            "magento/magento2-base": {
                "Fix file custom option API processing (mage-os#210)": "patches/magento/magento2-base/fix-file-custom-option-api.patch"
            }
        }
    }
}

Step 3: Apply

composer install
# or
composer patches:repatch

Step 4: Post-install

bin/magento setup:di:compile
bin/magento cache:flush

Disclaimer

This is a community-provided backport. Test on a staging environment before applying to production.

--- a/Api/ImageContentUploaderInterface.php
+++ b/Api/ImageContentUploaderInterface.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * Copyright 2025 Adobe
+ * All Rights Reserved.
+ */
+
+declare(strict_types=1);
+
+namespace Magento\Framework\Api;
+
+use Magento\Framework\Api\Data\ImageContentInterface;
+use Magento\Framework\Exception\FileSystemException;
+use Magento\Framework\Exception\LocalizedException;
+use Magento\Framework\Filesystem\Directory\WriteInterface;
+
+interface ImageContentUploaderInterface extends ImageProcessorInterface
+{
+ public const CASE_SENSITIVE = 1;
+ public const PATH_DISPERSION = 2;
+ public const RENAME_IF_EXIST = 4;
+
+ /**
+ * Move image content to a temp directory.
+ *
+ * @param ImageContentInterface $imageContent
+ * @param bool $validate
+ * @return string
+ * @throws FileSystemException
+ * @throws LocalizedException
+ */
+ public function saveToTmpDir(
+ ImageContentInterface $imageContent,
+ bool $validate = true
+ ): string;
+
+ /**
+ * Move image content from temp to the specified directory.
+ *
+ * @param ImageContentInterface $imageContent
+ * @param string $tmpFileName
+ * @param WriteInterface $destinationDirectory
+ * @param string|null $destinationPath
+ * @param string|null $fileName
+ * @param int $flags Flags is a bitmask that controls the operations that can be performed on the file.
+ * The default value is 0 meaning self::CASE_SENSITIVE | self::PATH_DISPERSION | self::RENAME_IF_EXIST.
+ * @return string|null
+ * @throws FileSystemException
+ * @throws LocalizedException
+ */
+ public function moveFromTmpDir(
+ ImageContentInterface $imageContent,
+ string $tmpFileName,
+ WriteInterface $destinationDirectory,
+ ?string $destinationPath = null,
+ ?string $fileName = null,
+ int $flags = 0
+ ): ?string;
+}
--- a/Api/ImageProcessor.php
+++ b/Api/ImageProcessor.php
@@ -10,12 +10,13 @@
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\Exception\InputException;
use Magento\Framework\Filesystem;
+use Magento\Framework\Filesystem\Directory\WriteInterface;
use Magento\Framework\Phrase;
/**
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
-class ImageProcessor implements ImageProcessorInterface
+class ImageProcessor implements ImageProcessorInterface, ImageContentUploaderInterface
{
/**
* @var array
@@ -135,7 +136,30 @@
*/
public function processImageContent($entityType, $imageContent)
{
- if (!$this->contentValidator->isValid($imageContent)) {
+ $tmpFileName = $this->saveToTmpDir($imageContent);
+
+ try {
+ return $this->moveFromTmpDir(
+ $imageContent,
+ $tmpFileName,
+ $this->mediaDirectory,
+ (string) $entityType,
+ );
+ } catch (\Exception $e) {
+ $this->logger->critical($e);
+ }
+
+ return '';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function saveToTmpDir(
+ ImageContentInterface $imageContent,
+ bool $validate = true
+ ): string {
+ if ($validate && !$this->contentValidator->isValid($imageContent)) {
throw new InputException(new Phrase('The image content is invalid. Verify the content and try again.'));
}
@@ -147,21 +171,33 @@
$tmpFileName = substr(md5(rand()), 0, 7) . '.' . $fileName;
$tmpDirectory->writeFile($tmpFileName, $fileContent);
- $fileAttributes = [
- 'tmp_name' => $tmpDirectory->getAbsolutePath() . $tmpFileName,
- 'name' => $imageContent->getName()
- ];
+ return $tmpFileName;
+ }
- try {
- $this->uploader->processFileAttributes($fileAttributes);
- $this->uploader->setFilesDispersion(true);
- $this->uploader->setFilenamesCaseSensitivity(false);
- $this->uploader->setAllowRenameFiles(true);
- $destinationFolder = $entityType;
- $this->uploader->save($this->mediaDirectory->getAbsolutePath($destinationFolder), $fileName);
- } catch (\Exception $e) {
- $this->logger->critical($e);
- }
+ /**
+ * @inheritDoc
+ */
+ public function moveFromTmpDir(
+ ImageContentInterface $imageContent,
+ string $tmpFileName,
+ WriteInterface $destinationDirectory,
+ ?string $destinationPath = null,
+ ?string $fileName = null,
+ int $flags = 0
+ ): ?string {
+ $flags = $flags ?: (self::CASE_SENSITIVE | self::PATH_DISPERSION | self::RENAME_IF_EXIST);
+ $fileName = $fileName ?? $this->getFileName($imageContent);
+ $tmpDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::SYS_TMP);
+ $this->uploader->processFileAttributes([
+ 'tmp_name' => $tmpDirectory->getAbsolutePath() . $tmpFileName,
+ 'name' => $fileName
+ ]);
+ $this->uploader->setFilesDispersion((bool)($flags & self::PATH_DISPERSION));
+ // setFilenamesCaseSensitivity is actually setting whether the filenames are case-insensitive,
+ // meaning that passing TRUE makes the filenames case-insensitive and vice versa.
+ $this->uploader->setFilenamesCaseSensitivity(!($flags & self::CASE_SENSITIVE));
+ $this->uploader->setAllowRenameFiles((bool)($flags & self::RENAME_IF_EXIST));
+ $this->uploader->save($this->mediaDirectory->getAbsolutePath($destinationPath), $fileName);
return $this->uploader->getUploadedFileName();
}
--- a/Api/ImageContentUploaderInterface.php 2026-03-23 09:06:54.853860818 +0000
+++ b/Api/ImageContentUploaderInterface.php 2026-03-23 13:35:43.521248463 +0000
@@ -0,0 +1,58 @@
+<?php
+/**
+ * Copyright 2025 Adobe
+ * All Rights Reserved.
+ */
+
+declare(strict_types=1);
+
+namespace Magento\Framework\Api;
+
+use Magento\Framework\Api\Data\ImageContentInterface;
+use Magento\Framework\Exception\FileSystemException;
+use Magento\Framework\Exception\LocalizedException;
+use Magento\Framework\Filesystem\Directory\WriteInterface;
+
+interface ImageContentUploaderInterface extends ImageProcessorInterface
+{
+ public const CASE_SENSITIVE = 1;
+ public const PATH_DISPERSION = 2;
+ public const RENAME_IF_EXIST = 4;
+
+ /**
+ * Move image content to a temp directory.
+ *
+ * @param ImageContentInterface $imageContent
+ * @param bool $validate
+ * @return string
+ * @throws FileSystemException
+ * @throws LocalizedException
+ */
+ public function saveToTmpDir(
+ ImageContentInterface $imageContent,
+ bool $validate = true
+ ): string;
+
+ /**
+ * Move image content from temp to the specified directory.
+ *
+ * @param ImageContentInterface $imageContent
+ * @param string $tmpFileName
+ * @param WriteInterface $destinationDirectory
+ * @param string|null $destinationPath
+ * @param string|null $fileName
+ * @param int $flags Flags is a bitmask that controls the operations that can be performed on the file.
+ * The default value is 0 meaning self::CASE_SENSITIVE | self::PATH_DISPERSION | self::RENAME_IF_EXIST.
+ * @return string|null
+ * @throws FileSystemException
+ * @throws LocalizedException
+ */
+ public function moveFromTmpDir(
+ ImageContentInterface $imageContent,
+ string $tmpFileName,
+ WriteInterface $destinationDirectory,
+ ?string $destinationPath = null,
+ ?string $fileName = null,
+ int $flags = 0
+ ): ?string;
+}
--- a/Api/ImageProcessor.php 2026-03-23 13:35:06.231893176 +0000
+++ b/Api/ImageProcessor.php 2026-03-23 13:35:33.241150496 +0000
@@ -10,12 +10,13 @@
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\Exception\InputException;
use Magento\Framework\Filesystem;
+use Magento\Framework\Filesystem\Directory\WriteInterface;
use Magento\Framework\Phrase;
/**
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
-class ImageProcessor implements ImageProcessorInterface
+class ImageProcessor implements ImageProcessorInterface, ImageContentUploaderInterface
{
/**
* @var array
@@ -135,7 +136,30 @@
*/
public function processImageContent($entityType, $imageContent)
{
- if (!$this->contentValidator->isValid($imageContent)) {
+ $tmpFileName = $this->saveToTmpDir($imageContent);
+
+ try {
+ return $this->moveFromTmpDir(
+ $imageContent,
+ $tmpFileName,
+ $this->mediaDirectory,
+ (string) $entityType,
+ );
+ } catch (\Exception $e) {
+ $this->logger->critical($e);
+ }
+
+ return '';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function saveToTmpDir(
+ ImageContentInterface $imageContent,
+ bool $validate = true
+ ): string {
+ if ($validate && !$this->contentValidator->isValid($imageContent)) {
throw new InputException(new Phrase('The image content is invalid. Verify the content and try again.'));
}
@@ -147,21 +171,33 @@
$tmpFileName = substr(md5(rand()), 0, 7) . '.' . $fileName;
$tmpDirectory->writeFile($tmpFileName, $fileContent);
- $fileAttributes = [
- 'tmp_name' => $tmpDirectory->getAbsolutePath() . $tmpFileName,
- 'name' => $imageContent->getName()
- ];
+ return $tmpFileName;
+ }
- try {
- $this->uploader->processFileAttributes($fileAttributes);
- $this->uploader->setFilesDispersion(true);
- $this->uploader->setFilenamesCaseSensitivity(false);
- $this->uploader->setAllowRenameFiles(true);
- $destinationFolder = $entityType;
- $this->uploader->save($this->mediaDirectory->getAbsolutePath($destinationFolder), $fileName);
- } catch (\Exception $e) {
- $this->logger->critical($e);
- }
+ /**
+ * @inheritDoc
+ */
+ public function moveFromTmpDir(
+ ImageContentInterface $imageContent,
+ string $tmpFileName,
+ WriteInterface $destinationDirectory,
+ ?string $destinationPath = null,
+ ?string $fileName = null,
+ int $flags = 0
+ ): ?string {
+ $flags = $flags ?: (self::CASE_SENSITIVE | self::PATH_DISPERSION | self::RENAME_IF_EXIST);
+ $fileName = $fileName ?? $this->getFileName($imageContent);
+ $tmpDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::SYS_TMP);
+ $this->uploader->processFileAttributes([
+ 'tmp_name' => $tmpDirectory->getAbsolutePath() . $tmpFileName,
+ 'name' => $fileName
+ ]);
+ $this->uploader->setFilesDispersion((bool)($flags & self::PATH_DISPERSION));
+ // setFilenamesCaseSensitivity is actually setting whether the filenames are case-insensitive,
+ // meaning that passing TRUE makes the filenames case-insensitive and vice versa.
+ $this->uploader->setFilenamesCaseSensitivity(!($flags & self::CASE_SENSITIVE));
+ $this->uploader->setAllowRenameFiles((bool)($flags & self::RENAME_IF_EXIST));
+ $this->uploader->save($this->mediaDirectory->getAbsolutePath($destinationPath), $fileName);
return $this->uploader->getUploadedFileName();
}
--- a/app/etc/di.xml
+++ b/app/etc/di.xml
@@ -155,6 +155,7 @@
<preference for="Magento\Framework\Api\Data\ImageContentInterface" type="Magento\Framework\Api\ImageContent" />
<preference for="Magento\Framework\Api\ImageContentValidatorInterface" type="Magento\Framework\Api\ImageContentValidator" />
<preference for="Magento\Framework\Api\ImageProcessorInterface" type="Magento\Framework\Api\ImageProcessor" />
+ <preference for="Magento\Framework\Api\ImageContentUploaderInterface" type="Magento\Framework\Api\ImageProcessor" />
<preference for="Magento\Framework\Code\Reader\ClassReaderInterface" type="Magento\Framework\Code\Reader\ClassReader" />
<preference for="Magento\Framework\Stdlib\DateTime\DateTimeFormatterInterface" type="Magento\Framework\Stdlib\DateTime\DateTimeFormatter"/>
<preference for="Magento\Framework\Api\Search\SearchInterface" type="Magento\Framework\Search\Search"/>
diff --color -ruN base-patch-orig/app/etc/di.xml base-patch/app/etc/di.xml
--- a/app/etc/di.xml 2026-03-23 14:07:00.209707435 +0000
+++ b/app/etc/di.xml 2026-03-23 14:07:13.915827247 +0000
@@ -156,6 +156,7 @@
<preference for="Magento\Framework\Api\Data\ImageContentInterface" type="Magento\Framework\Api\ImageContent" />
<preference for="Magento\Framework\Api\ImageContentValidatorInterface" type="Magento\Framework\Api\ImageContentValidator" />
<preference for="Magento\Framework\Api\ImageProcessorInterface" type="Magento\Framework\Api\ImageProcessor" />
+ <preference for="Magento\Framework\Api\ImageContentUploaderInterface" type="Magento\Framework\Api\ImageProcessor" />
<preference for="Magento\Framework\Code\Reader\ClassReaderInterface" type="Magento\Framework\Code\Reader\ClassReader" />
<preference for="Magento\Framework\Stdlib\DateTime\DateTimeFormatterInterface" type="Magento\Framework\Stdlib\DateTime\DateTimeFormatter"/>
<preference for="Magento\Framework\Api\Search\SearchInterface" type="Magento\Framework\Search\Search"/>
diff -ruN a/Model/CustomOptions/CustomOptionProcessor.php b/Model/CustomOptions/CustomOptionProcessor.php
--- a/Model/CustomOptions/CustomOptionProcessor.php 2026-03-25 06:22:38.112690559 +0000
+++ b/Model/CustomOptions/CustomOptionProcessor.php 2026-03-25 06:23:16.360092178 +0000
@@ -5,12 +5,20 @@
*/
namespace Magento\Catalog\Model\CustomOptions;
+use Magento\Catalog\Api\Data\CustomOptionInterface;
+use Magento\Catalog\Api\Data\ProductCustomOptionInterface;
+use Magento\Catalog\Api\ProductRepositoryInterface;
+use Magento\Catalog\Model\Product\Option\Type\File\ImageContentProcessor;
use Magento\Framework\DataObject;
+use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Quote\Api\Data\CartItemInterface;
use Magento\Quote\Model\Quote\Item\CartItemProcessorInterface;
use Magento\Quote\Api\Data\ProductOptionExtensionFactory;
use Magento\Quote\Model\Quote\ProductOptionFactory;
+/**
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
+ */
class CustomOptionProcessor implements CartItemProcessorInterface
{
/**
@@ -46,18 +54,32 @@
private $serializer;
/**
+ * @var ProductRepositoryInterface
+ */
+ private $productRepository;
+
+ /**
+ * @var ImageContentProcessor
+ */
+ private $imageContentProcessor;
+
+ /**
* @param DataObject\Factory $objectFactory
* @param ProductOptionFactory $productOptionFactory
* @param ProductOptionExtensionFactory $extensionFactory
* @param CustomOptionFactory $customOptionFactory
* @param \Magento\Framework\Serialize\Serializer\Json|null $serializer
+ * @param ProductRepositoryInterface|null $productRepository
+ * @param ImageContentProcessor|null $imageContentProcessor
*/
public function __construct(
\Magento\Framework\DataObject\Factory $objectFactory,
\Magento\Quote\Model\Quote\ProductOptionFactory $productOptionFactory,
\Magento\Quote\Api\Data\ProductOptionExtensionFactory $extensionFactory,
\Magento\Catalog\Model\CustomOptions\CustomOptionFactory $customOptionFactory,
- \Magento\Framework\Serialize\Serializer\Json $serializer = null
+ \Magento\Framework\Serialize\Serializer\Json $serializer = null,
+ ?ProductRepositoryInterface $productRepository = null,
+ ?ImageContentProcessor $imageContentProcessor = null
) {
$this->objectFactory = $objectFactory;
$this->productOptionFactory = $productOptionFactory;
@@ -65,6 +87,10 @@
$this->customOptionFactory = $customOptionFactory;
$this->serializer = $serializer ?: \Magento\Framework\App\ObjectManager::getInstance()
->get(\Magento\Framework\Serialize\Serializer\Json::class);
+ $this->productRepository = $productRepository ?: \Magento\Framework\App\ObjectManager::getInstance()
+ ->get(ProductRepositoryInterface::class);
+ $this->imageContentProcessor = $imageContentProcessor ?: \Magento\Framework\App\ObjectManager::getInstance()
+ ->get(ImageContentProcessor::class);
}
/**
@@ -72,17 +98,19 @@
*/
public function convertToBuyRequest(CartItemInterface $cartItem)
{
- if ($cartItem->getProductOption()
- && $cartItem->getProductOption()->getExtensionAttributes()
- && $cartItem->getProductOption()->getExtensionAttributes()->getCustomOptions()) {
- $customOptions = $cartItem->getProductOption()->getExtensionAttributes()->getCustomOptions();
- if (!empty($customOptions) && is_array($customOptions)) {
- $requestData = [];
- foreach ($customOptions as $option) {
- $requestData['options'][$option->getOptionId()] = $option->getOptionValue();
- }
- return $this->objectFactory->create($requestData);
+ $customOptions = $cartItem->getProductOption()
+ ?->getExtensionAttributes()
+ ?->getCustomOptions();
+ if (!empty($customOptions)) {
+ $productOptions = $this->getProductCustomOptions($cartItem);
+ $requestData = [];
+ foreach ($customOptions as $option) {
+ $requestData['options'][$option->getOptionId()] = $this->getCustomOptionValue(
+ $option,
+ $productOptions[$option->getOptionId()] ?? null
+ );
}
+ return $this->objectFactory->create($requestData);
}
return null;
}
@@ -99,7 +127,6 @@
? $cartItem->getProductOption()
: $this->productOptionFactory->create();
- /** @var \Magento\Quote\Api\Data\ProductOptionExtensionInterface $extensibleAttribute */
$extensibleAttribute = $productOption->getExtensionAttributes()
? $productOption->getExtensionAttributes()
: $this->extensionFactory->create();
@@ -136,7 +163,7 @@
protected function updateOptionsValues(array &$options)
{
foreach ($options as $optionId => &$optionValue) {
- /** @var \Magento\Catalog\Model\CustomOptions\CustomOption $option */
+ /** @var CustomOption $option */
$option = $this->customOptionFactory->create();
$option->setOptionId($optionId);
if (is_array($optionValue)) {
@@ -200,4 +227,55 @@
}
return $this->urlBuilder;
}
+
+ /**
+ * Get Product Options
+ *
+ * @param CartItemInterface $cartItem
+ * @return ProductCustomOptionInterface[]
+ */
+ private function getProductCustomOptions(CartItemInterface $cartItem): array
+ {
+ try {
+ $product = $this->productRepository->get($cartItem->getSku());
+ } catch (NoSuchEntityException) {
+ $product = null;
+ }
+
+ $options = [];
+ foreach ($product?->getHasOptions() ? $product->getOptions() : [] as $option) {
+ $options[$option->getOptionId()] = $option;
+ }
+ return $options;
+ }
+
+ /**
+ * Get custom option value depending on the type of custom option
+ *
+ * @param CustomOptionInterface $customOption
+ * @param ProductCustomOptionInterface|null $productCustomOption
+ * @return string|array|null
+ */
+ private function getCustomOptionValue(
+ CustomOptionInterface $customOption,
+ ?ProductCustomOptionInterface $productCustomOption = null
+ ): mixed {
+ if ($customOption->getExtensionAttributes()?->getFileInfo()) {
+ if ($productCustomOption
+ && $productCustomOption->getType() === ProductCustomOptionInterface::OPTION_TYPE_FILE
+ ) {
+ return $this->imageContentProcessor->process(
+ $customOption->getExtensionAttributes()->getFileInfo(),
+ $productCustomOption
+ );
+ } elseif ($customOption instanceof CustomOption) {
+ // Check if the custom option is an instance of CustomOption for backward compatibility
+ // Bypass CustomOption::getOptionValue as the current implementation would process the file
+ // even if it is not a file option.
+ return $customOption->getData(CustomOptionInterface::OPTION_VALUE);
+ }
+ }
+
+ return $customOption->getOptionValue();
+ }
}
diff -ruN a/Model/Product/Option/Type/File/ImageContentProcessor.php b/Model/Product/Option/Type/File/ImageContentProcessor.php
--- a/Model/Product/Option/Type/File/ImageContentProcessor.php 2026-03-25 06:22:38.113690570 +0000
+++ b/Model/Product/Option/Type/File/ImageContentProcessor.php 2026-03-25 06:23:41.430248005 +0000
@@ -0,0 +1,161 @@
+<?php
+/**
+ * Copyright 2025 Adobe
+ * All Rights Reserved.
+ */
+
+declare(strict_types=1);
+
+namespace Magento\Catalog\Model\Product\Option\Type\File;
+
+use Magento\Catalog\Model\Product\Option;
+use Magento\Framework\Api\Data\ImageContentInterface;
+use Magento\Framework\Api\ImageContentUploaderInterface;
+use Magento\Framework\App\Config\ScopeConfigInterface;
+use Magento\Framework\App\Filesystem\DirectoryList;
+use Magento\Framework\Exception\LocalizedException;
+use Magento\Framework\File\Size;
+use Magento\Framework\Filesystem;
+use Magento\Framework\Filesystem\Io\File as IoFile;
+use Magento\Framework\Image\Factory;
+use Laminas\Validator\ValidatorChain;
+use Magento\MediaStorage\Model\File\Validator\NotProtectedExtension;
+
+class ImageContentProcessor extends Validator
+{
+ private const QUOTE_PATH = 'custom_options/quote';
+ private const ORDER_PATH = 'custom_options/order';
+
+ /**
+ * @param ScopeConfigInterface $scopeConfig
+ * @param Size $fileSize
+ * @param Filesystem $filesystem
+ * @param NotProtectedExtension $extensionValidator
+ * @param ImageContentUploaderInterface $uploader
+ * @param Factory $imageFactory
+ * @param IoFile $ioFile
+ * @param string $quotePath
+ * @param string $orderPath
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList)
+ */
+ public function __construct(
+ ScopeConfigInterface $scopeConfig,
+ Size $fileSize,
+ private readonly Filesystem $filesystem,
+ private readonly NotProtectedExtension $extensionValidator,
+ private readonly ImageContentUploaderInterface $uploader,
+ private readonly Factory $imageFactory,
+ private readonly IoFile $ioFile,
+ private readonly string $quotePath = self::QUOTE_PATH,
+ private readonly string $orderPath = self::ORDER_PATH
+ ) {
+ parent::__construct($scopeConfig, $filesystem, $fileSize);
+ }
+
+ /**
+ * Process image content for product option and return file information
+ *
+ * @param ImageContentInterface $imageContent
+ * @param Option $option
+ * @return array|null
+ * @throws LocalizedException
+ */
+ public function process(ImageContentInterface $imageContent, Option $option): ?array
+ {
+ if (!$imageContent->getBase64EncodedData()) {
+ return null;
+ }
+ $this->validateBeforeSaveToTmp($imageContent, $option);
+
+ $tmpFilename = $this->uploader->saveToTmpDir($imageContent);
+ $validatorChain = new ValidatorChain();
+ $validatorChain = $this->buildImageValidator($validatorChain, $option);
+
+ $tmpDirectory = $this->filesystem->getDirectoryRead(DirectoryList::SYS_TMP);
+ if ($validatorChain->isValid($tmpDirectory->getAbsolutePath($tmpFilename))) {
+ $size = $tmpDirectory->stat($tmpFilename)['size'];
+ if (!$size) {
+ throw new LocalizedException(__('The file is empty. Select another file and try again.'));
+ }
+ $imageInstance = $this->imageFactory->create($tmpDirectory->getAbsolutePath($tmpFilename));
+ $width = $imageInstance->getOriginalWidth();
+ $height = $imageInstance->getOriginalHeight();
+
+ $fileHash = hash('sha256', $tmpDirectory->readFile($tmpFilename));
+ $mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA);
+ $filePath = $this->uploader->moveFromTmpDir(
+ $imageContent,
+ $tmpFilename,
+ $mediaDirectory,
+ $this->quotePath
+ );
+ $fileFullPath = $mediaDirectory->getAbsolutePath($this->quotePath . $filePath);
+
+ return [
+ 'type' => $imageContent->getType(),
+ 'title' => $imageContent->getName(),
+ 'quote_path' => $this->quotePath . $filePath,
+ 'order_path' => $this->orderPath . $filePath,
+ 'fullpath' => $fileFullPath,
+ 'size' => $size,
+ 'width' => $width,
+ 'height' => $height,
+ 'secret_key' => substr($fileHash, 0, 20),
+ ];
+ } elseif ($validatorChain->getMessages()) {
+ $errors = $this->getValidatorErrors(
+ array_keys($validatorChain->getMessages()),
+ [
+ 'title' => $imageContent->getName(),
+ 'name' => $imageContent->getName(),
+ 'tmp_name' => $tmpDirectory->getAbsolutePath($tmpFilename),
+ 'type' => $imageContent->getType(),
+ 'size' => 0,
+ ],
+ $option
+ );
+
+ if (count($errors) > 0) {
+ throw new LocalizedException(__(implode("\n", $errors)));
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get extension based on filename
+ *
+ * @param string $filename
+ * @return string|null
+ */
+ private function getFileExtension(string $filename): ?string
+ {
+ $pathInfo = $this->ioFile->getPathInfo($filename);
+
+ if (!isset($pathInfo['extension'])) {
+ return null;
+ }
+ return $pathInfo['extension'];
+ }
+
+ /**
+ * Validate image content before saving to temporary directory
+ *
+ * @param ImageContentInterface $imageContent
+ * @param Option $option
+ * @return void
+ * @throws LocalizedException
+ */
+ private function validateBeforeSaveToTmp(ImageContentInterface $imageContent, Option $option): void
+ {
+ $extension = $this->getFileExtension($imageContent->getName() ?: '');
+ if ($extension !== null && (!$extension || !$this->extensionValidator->isValid($extension))) {
+ throw new LocalizedException(__(
+ "The file '%1' for '%2' has an invalid extension.",
+ $imageContent->getName(),
+ $option->getTitle()
+ ));
+ }
+ }
+}
diff -ruN a/etc/di.xml b/etc/di.xml
--- a/etc/di.xml 2026-03-25 06:23:53.398184588 +0000
+++ b/etc/di.xml 2026-03-25 06:23:53.400184578 +0000
@@ -1343,4 +1343,10 @@
</argument>
</arguments>
</type>
+ <type name="Magento\Catalog\Model\Product\Option\Type\File\ImageContentProcessor">
+ <arguments>
+ <argument name="quotePath" xsi:type="string">custom_options/quote</argument>
+ <argument name="orderPath" xsi:type="string">custom_options/order</argument>
+ </arguments>
+ </type>
</config>
diff --color -ruN catalog-patch-orig/Model/CustomOptions/CustomOptionProcessor.php catalog-patch/Model/CustomOptions/CustomOptionProcessor.php
--- a/Model/CustomOptions/CustomOptionProcessor.php 2026-03-23 14:03:43.362996246 +0000
+++ b/Model/CustomOptions/CustomOptionProcessor.php 2026-03-23 14:04:24.216349753 +0000
@@ -5,12 +5,20 @@
*/
namespace Magento\Catalog\Model\CustomOptions;
+use Magento\Catalog\Api\Data\CustomOptionInterface;
+use Magento\Catalog\Api\Data\ProductCustomOptionInterface;
+use Magento\Catalog\Api\ProductRepositoryInterface;
+use Magento\Catalog\Model\Product\Option\Type\File\ImageContentProcessor;
use Magento\Framework\DataObject;
+use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Quote\Api\Data\CartItemInterface;
use Magento\Quote\Model\Quote\Item\CartItemProcessorInterface;
use Magento\Quote\Api\Data\ProductOptionExtensionFactory;
use Magento\Quote\Model\Quote\ProductOptionFactory;
+/**
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
+ */
class CustomOptionProcessor implements CartItemProcessorInterface
{
/**
@@ -46,18 +54,32 @@
private $serializer;
/**
+ * @var ProductRepositoryInterface
+ */
+ private $productRepository;
+
+ /**
+ * @var ImageContentProcessor
+ */
+ private $imageContentProcessor;
+
+ /**
* @param DataObject\Factory $objectFactory
* @param ProductOptionFactory $productOptionFactory
* @param ProductOptionExtensionFactory $extensionFactory
* @param CustomOptionFactory $customOptionFactory
* @param \Magento\Framework\Serialize\Serializer\Json|null $serializer
+ * @param ProductRepositoryInterface|null $productRepository
+ * @param ImageContentProcessor|null $imageContentProcessor
*/
public function __construct(
\Magento\Framework\DataObject\Factory $objectFactory,
\Magento\Quote\Model\Quote\ProductOptionFactory $productOptionFactory,
\Magento\Quote\Api\Data\ProductOptionExtensionFactory $extensionFactory,
\Magento\Catalog\Model\CustomOptions\CustomOptionFactory $customOptionFactory,
- ?\Magento\Framework\Serialize\Serializer\Json $serializer = null
+ ?\Magento\Framework\Serialize\Serializer\Json $serializer = null,
+ ?ProductRepositoryInterface $productRepository = null,
+ ?ImageContentProcessor $imageContentProcessor = null
) {
$this->objectFactory = $objectFactory;
$this->productOptionFactory = $productOptionFactory;
@@ -65,6 +87,10 @@
$this->customOptionFactory = $customOptionFactory;
$this->serializer = $serializer ?: \Magento\Framework\App\ObjectManager::getInstance()
->get(\Magento\Framework\Serialize\Serializer\Json::class);
+ $this->productRepository = $productRepository ?: \Magento\Framework\App\ObjectManager::getInstance()
+ ->get(ProductRepositoryInterface::class);
+ $this->imageContentProcessor = $imageContentProcessor ?: \Magento\Framework\App\ObjectManager::getInstance()
+ ->get(ImageContentProcessor::class);
}
/**
@@ -72,17 +98,19 @@
*/
public function convertToBuyRequest(CartItemInterface $cartItem)
{
- if ($cartItem->getProductOption()
- && $cartItem->getProductOption()->getExtensionAttributes()
- && $cartItem->getProductOption()->getExtensionAttributes()->getCustomOptions()) {
- $customOptions = $cartItem->getProductOption()->getExtensionAttributes()->getCustomOptions();
- if (!empty($customOptions) && is_array($customOptions)) {
- $requestData = [];
- foreach ($customOptions as $option) {
- $requestData['options'][$option->getOptionId()] = $option->getOptionValue();
- }
- return $this->objectFactory->create($requestData);
+ $customOptions = $cartItem->getProductOption()
+ ?->getExtensionAttributes()
+ ?->getCustomOptions();
+ if (!empty($customOptions)) {
+ $productOptions = $this->getProductCustomOptions($cartItem);
+ $requestData = [];
+ foreach ($customOptions as $option) {
+ $requestData['options'][$option->getOptionId()] = $this->getCustomOptionValue(
+ $option,
+ $productOptions[$option->getOptionId()] ?? null
+ );
}
+ return $this->objectFactory->create($requestData);
}
return null;
}
@@ -99,7 +127,6 @@
? $cartItem->getProductOption()
: $this->productOptionFactory->create();
- /** @var \Magento\Quote\Api\Data\ProductOptionExtensionInterface $extensibleAttribute */
$extensibleAttribute = $productOption->getExtensionAttributes()
? $productOption->getExtensionAttributes()
: $this->extensionFactory->create();
@@ -136,7 +163,7 @@
protected function updateOptionsValues(array &$options)
{
foreach ($options as $optionId => &$optionValue) {
- /** @var \Magento\Catalog\Model\CustomOptions\CustomOption $option */
+ /** @var CustomOption $option */
$option = $this->customOptionFactory->create();
$option->setOptionId($optionId);
if (is_array($optionValue)) {
@@ -204,4 +231,55 @@
}
return $this->urlBuilder;
}
+
+ /**
+ * Get Product Options
+ *
+ * @param CartItemInterface $cartItem
+ * @return ProductCustomOptionInterface[]
+ */
+ private function getProductCustomOptions(CartItemInterface $cartItem): array
+ {
+ try {
+ $product = $this->productRepository->get($cartItem->getSku());
+ } catch (NoSuchEntityException) {
+ $product = null;
+ }
+
+ $options = [];
+ foreach ($product?->getHasOptions() ? $product->getOptions() : [] as $option) {
+ $options[$option->getOptionId()] = $option;
+ }
+ return $options;
+ }
+
+ /**
+ * Get custom option value depending on the type of custom option
+ *
+ * @param CustomOptionInterface $customOption
+ * @param ProductCustomOptionInterface|null $productCustomOption
+ * @return string|array|null
+ */
+ private function getCustomOptionValue(
+ CustomOptionInterface $customOption,
+ ?ProductCustomOptionInterface $productCustomOption = null
+ ): mixed {
+ if ($customOption->getExtensionAttributes()?->getFileInfo()) {
+ if ($productCustomOption
+ && $productCustomOption->getType() === ProductCustomOptionInterface::OPTION_TYPE_FILE
+ ) {
+ return $this->imageContentProcessor->process(
+ $customOption->getExtensionAttributes()->getFileInfo(),
+ $productCustomOption
+ );
+ } elseif ($customOption instanceof CustomOption) {
+ // Check if the custom option is an instance of CustomOption for backward compatibility
+ // Bypass CustomOption::getOptionValue as the current implementation would process the file
+ // even if it is not a file option.
+ return $customOption->getData(CustomOptionInterface::OPTION_VALUE);
+ }
+ }
+
+ return $customOption->getOptionValue();
+ }
}
diff --color -ruN catalog-patch-orig/Model/Product/Option/Type/File/ImageContentProcessor.php catalog-patch/Model/Product/Option/Type/File/ImageContentProcessor.php
--- a/Model/Product/Option/Type/File/ImageContentProcessor.php 2026-03-23 14:03:43.376996367 +0000
+++ b/Model/Product/Option/Type/File/ImageContentProcessor.php 2026-03-23 14:04:44.327524119 +0000
@@ -0,0 +1,163 @@
+<?php
+/**
+ * Copyright 2025 Adobe
+ * All Rights Reserved.
+ */
+
+declare(strict_types=1);
+
+namespace Magento\Catalog\Model\Product\Option\Type\File;
+
+use Magento\Catalog\Model\Product\Option;
+use Magento\Framework\Api\Data\ImageContentInterface;
+use Magento\Framework\Api\ImageContentUploaderInterface;
+use Magento\Framework\App\Config\ScopeConfigInterface;
+use Magento\Framework\App\Filesystem\DirectoryList;
+use Magento\Framework\Exception\LocalizedException;
+use Magento\Framework\File\Size;
+use Magento\Framework\Filesystem;
+use Magento\Framework\Filesystem\Io\File as IoFile;
+use Magento\Framework\Image\Factory;
+use Magento\Framework\Validator\ValidatorChainFactory;
+use Magento\MediaStorage\Model\File\Validator\NotProtectedExtension;
+
+class ImageContentProcessor extends Validator
+{
+ private const QUOTE_PATH = 'custom_options/quote';
+ private const ORDER_PATH = 'custom_options/order';
+
+ /**
+ * @param ScopeConfigInterface $scopeConfig
+ * @param Size $fileSize
+ * @param Filesystem $filesystem
+ * @param NotProtectedExtension $extensionValidator
+ * @param ImageContentUploaderInterface $uploader
+ * @param ValidatorChainFactory $validatorChainFactory
+ * @param Factory $imageFactory
+ * @param IoFile $ioFile
+ * @param string $quotePath
+ * @param string $orderPath
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList)
+ */
+ public function __construct(
+ ScopeConfigInterface $scopeConfig,
+ Size $fileSize,
+ private readonly Filesystem $filesystem,
+ private readonly NotProtectedExtension $extensionValidator,
+ private readonly ImageContentUploaderInterface $uploader,
+ private readonly ValidatorChainFactory $validatorChainFactory,
+ private readonly Factory $imageFactory,
+ private readonly IoFile $ioFile,
+ private readonly string $quotePath = self::QUOTE_PATH,
+ private readonly string $orderPath = self::ORDER_PATH
+ ) {
+ parent::__construct($scopeConfig, $filesystem, $fileSize);
+ }
+
+ /**
+ * Process image content for product option and return file information
+ *
+ * @param ImageContentInterface $imageContent
+ * @param Option $option
+ * @return array|null
+ * @throws LocalizedException
+ */
+ public function process(ImageContentInterface $imageContent, Option $option): ?array
+ {
+ if (!$imageContent->getBase64EncodedData()) {
+ return null;
+ }
+ $this->validateBeforeSaveToTmp($imageContent, $option);
+
+ $tmpFilename = $this->uploader->saveToTmpDir($imageContent);
+ $validatorChain = $this->validatorChainFactory->create();
+ $validatorChain = $this->buildImageValidator($validatorChain, $option);
+
+ $tmpDirectory = $this->filesystem->getDirectoryRead(DirectoryList::SYS_TMP);
+ if ($validatorChain->isValid($tmpDirectory->getAbsolutePath($tmpFilename))) {
+ $size = $tmpDirectory->stat($tmpFilename)['size'];
+ if (!$size) {
+ throw new LocalizedException(__('The file is empty. Select another file and try again.'));
+ }
+ $imageInstance = $this->imageFactory->create($tmpDirectory->getAbsolutePath($tmpFilename));
+ $width = $imageInstance->getOriginalWidth();
+ $height = $imageInstance->getOriginalHeight();
+
+ $fileHash = hash('sha256', $tmpDirectory->readFile($tmpFilename));
+ $mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA);
+ $filePath = $this->uploader->moveFromTmpDir(
+ $imageContent,
+ $tmpFilename,
+ $mediaDirectory,
+ $this->quotePath
+ );
+ $fileFullPath = $mediaDirectory->getAbsolutePath($this->quotePath . $filePath);
+
+ return [
+ 'type' => $imageContent->getType(),
+ 'title' => $imageContent->getName(),
+ 'quote_path' => $this->quotePath . $filePath,
+ 'order_path' => $this->orderPath . $filePath,
+ 'fullpath' => $fileFullPath,
+ 'size' => $size,
+ 'width' => $width,
+ 'height' => $height,
+ 'secret_key' => substr($fileHash, 0, 20),
+ ];
+ } elseif ($validatorChain->getMessages()) {
+ $errors = $this->getValidatorErrors(
+ array_keys($validatorChain->getMessages()),
+ [
+ 'title' => $imageContent->getName(),
+ 'name' => $imageContent->getName(),
+ 'tmp_name' => $tmpDirectory->getAbsolutePath($tmpFilename),
+ 'type' => $imageContent->getType(),
+ 'size' => 0,
+ ],
+ $option
+ );
+
+ if (count($errors) > 0) {
+ throw new LocalizedException(__(implode("\n", $errors)));
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get extension based on filename
+ *
+ * @param string $filename
+ * @return string|null
+ */
+ private function getFileExtension(string $filename): ?string
+ {
+ $pathInfo = $this->ioFile->getPathInfo($filename);
+
+ if (!isset($pathInfo['extension'])) {
+ return null;
+ }
+ return $pathInfo['extension'];
+ }
+
+ /**
+ * Validate image content before saving to temporary directory
+ *
+ * @param ImageContentInterface $imageContent
+ * @param Option $option
+ * @return void
+ * @throws LocalizedException
+ */
+ private function validateBeforeSaveToTmp(ImageContentInterface $imageContent, Option $option): void
+ {
+ $extension = $this->getFileExtension($imageContent->getName() ?: '');
+ if ($extension !== null && (!$extension || !$this->extensionValidator->isValid($extension))) {
+ throw new LocalizedException(__(
+ "The file '%1' for '%2' has an invalid extension.",
+ $imageContent->getName(),
+ $option->getTitle()
+ ));
+ }
+ }
+}
diff --color -ruN catalog-patch-orig/etc/di.xml catalog-patch/etc/di.xml
--- a/etc/di.xml 2026-03-23 14:03:43.363996255 +0000
+++ b/etc/di.xml 2026-03-23 14:05:02.787684343 +0000
@@ -1396,4 +1396,10 @@
<argument name="attributeSetFactory" xsi:type="object">Magento\Catalog\Virtual\Product\Attribute\SetFactory</argument>
</arguments>
</type>
+ <type name="Magento\Catalog\Model\Product\Option\Type\File\ImageContentProcessor">
+ <arguments>
+ <argument name="quotePath" xsi:type="string">custom_options/quote</argument>
+ <argument name="orderPath" xsi:type="string">custom_options/order</argument>
+ </arguments>
+ </type>
</config>
diff -ruN a/Model/Quote/Item/CartItemCustomOptionsValidator.php b/Model/Quote/Item/CartItemCustomOptionsValidator.php
--- a/Model/Quote/Item/CartItemCustomOptionsValidator.php 2026-03-25 06:07:15.316693209 +0000
+++ b/Model/Quote/Item/CartItemCustomOptionsValidator.php 2026-03-25 06:07:29.218766126 +0000
@@ -0,0 +1,76 @@
+<?php
+/**
+ * Copyright 2025 Adobe
+ * All Rights Reserved.
+ */
+
+declare(strict_types=1);
+
+namespace Magento\Quote\Model\Quote\Item;
+
+use Magento\Catalog\Api\Data\ProductCustomOptionInterface;
+use Magento\Catalog\Api\ProductRepositoryInterface;
+use Magento\Framework\Exception\NoSuchEntityException;
+use Magento\Quote\Api\Data\CartInterface;
+use Magento\Quote\Api\Data\CartItemInterface;
+
+/**
+ * Cart item custom options validator.
+ */
+class CartItemCustomOptionsValidator implements CartItemValidatorInterface
+{
+ /**
+ * @param ProductRepositoryInterface $productRepository
+ */
+ public function __construct(
+ private readonly ProductRepositoryInterface $productRepository
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function validate(CartInterface $quote, CartItemInterface $cartItem): CartItemValidatorResultInterface
+ {
+ $errors = [];
+ $customOptions = $cartItem->getProductOption()
+ ?->getExtensionAttributes()
+ ?->getCustomOptions();
+
+ if (empty($customOptions)) {
+ return new CartItemValidatorResult($errors);
+ }
+
+ try {
+ $product = $this->productRepository->get($cartItem->getSku(), false, $quote->getStoreId());
+ } catch (NoSuchEntityException) {
+ return new CartItemValidatorResult($errors);
+ }
+
+ if (!$product->getHasOptions()) {
+ return new CartItemValidatorResult($errors);
+ }
+
+ $productOptions = [];
+ foreach ($product->getOptions() as $option) {
+ $productOptions[$option->getOptionId()] = $option;
+ }
+
+ foreach ($customOptions as $customOption) {
+ $optionId = $customOption->getOptionId();
+ $productOption = $productOptions[$optionId] ?? null;
+
+ if ($customOption->getExtensionAttributes()?->getFileInfo()
+ && $productOption
+ && $productOption->getType() !== ProductCustomOptionInterface::OPTION_TYPE_FILE
+ ) {
+ $errors[] = (string)__(
+ "Option '%1' does not support file uploads.",
+ $productOption->getTitle()
+ );
+ }
+ }
+
+ return new CartItemValidatorResult($errors);
+ }
+}
diff -ruN a/Model/Quote/Item/CartItemValidatorChain.php b/Model/Quote/Item/CartItemValidatorChain.php
--- a/Model/Quote/Item/CartItemValidatorChain.php 2026-03-25 06:07:15.316693209 +0000
+++ b/Model/Quote/Item/CartItemValidatorChain.php 2026-03-25 06:07:29.248766284 +0000
@@ -0,0 +1,46 @@
+<?php
+/**
+ * Copyright 2025 Adobe
+ * All Rights Reserved.
+ */
+
+declare(strict_types=1);
+
+namespace Magento\Quote\Model\Quote\Item;
+
+use Magento\Quote\Api\Data\CartInterface;
+use Magento\Quote\Api\Data\CartItemInterface;
+
+/**
+ * Cart item validator chain.
+ */
+class CartItemValidatorChain implements CartItemValidatorInterface
+{
+ /**
+ * @param CartItemValidatorInterface[] $validators
+ * @param bool $breakChainOnFailure
+ */
+ public function __construct(
+ private readonly array $validators = [],
+ private readonly bool $breakChainOnFailure = false
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function validate(CartInterface $quote, CartItemInterface $cartItem): CartItemValidatorResultInterface
+ {
+ $errors = [];
+ foreach ($this->validators as $validator) {
+ $result = $validator->validate($quote, $cartItem);
+ if ($result->getErrors()) {
+ $errors = array_merge($errors, $result->getErrors());
+ if ($this->breakChainOnFailure) {
+ break;
+ }
+ }
+ }
+ return new CartItemValidatorResult($errors);
+ }
+}
diff -ruN a/Model/Quote/Item/CartItemValidatorInterface.php b/Model/Quote/Item/CartItemValidatorInterface.php
--- a/Model/Quote/Item/CartItemValidatorInterface.php 2026-03-25 06:07:15.316693209 +0000
+++ b/Model/Quote/Item/CartItemValidatorInterface.php 2026-03-25 06:07:30.367772153 +0000
@@ -0,0 +1,27 @@
+<?php
+/**
+ * Copyright 2025 Adobe
+ * All Rights Reserved.
+ */
+
+declare(strict_types=1);
+
+namespace Magento\Quote\Model\Quote\Item;
+
+use Magento\Quote\Api\Data\CartInterface;
+use Magento\Quote\Api\Data\CartItemInterface;
+
+/**
+ * Cart item validator interface.
+ */
+interface CartItemValidatorInterface
+{
+ /**
+ * Validate cart item
+ *
+ * @param CartInterface $quote
+ * @param CartItemInterface $cartItem
+ * @return CartItemValidatorResultInterface
+ */
+ public function validate(CartInterface $quote, CartItemInterface $cartItem): CartItemValidatorResultInterface;
+}
diff -ruN a/Model/Quote/Item/CartItemValidatorResult.php b/Model/Quote/Item/CartItemValidatorResult.php
--- a/Model/Quote/Item/CartItemValidatorResult.php 2026-03-25 06:07:15.316693209 +0000
+++ b/Model/Quote/Item/CartItemValidatorResult.php 2026-03-25 06:07:31.656778915 +0000
@@ -0,0 +1,31 @@
+<?php
+/**
+ * Copyright 2025 Adobe
+ * All Rights Reserved.
+ */
+
+declare(strict_types=1);
+
+namespace Magento\Quote\Model\Quote\Item;
+
+/**
+ * Cart item validator result.
+ */
+class CartItemValidatorResult implements CartItemValidatorResultInterface
+{
+ /**
+ * @param string[] $errors
+ */
+ public function __construct(
+ private readonly array $errors = []
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getErrors(): array
+ {
+ return $this->errors;
+ }
+}
diff -ruN a/Model/Quote/Item/CartItemValidatorResultInterface.php b/Model/Quote/Item/CartItemValidatorResultInterface.php
--- a/Model/Quote/Item/CartItemValidatorResultInterface.php 2026-03-25 06:07:15.316693209 +0000
+++ b/Model/Quote/Item/CartItemValidatorResultInterface.php 2026-03-25 06:07:36.148017315 +0000
@@ -0,0 +1,22 @@
+<?php
+/**
+ * Copyright 2025 Adobe
+ * All Rights Reserved.
+ */
+
+declare(strict_types=1);
+
+namespace Magento\Quote\Model\Quote\Item;
+
+/**
+ * Cart item validator result interface.
+ */
+interface CartItemValidatorResultInterface
+{
+ /**
+ * Get errors
+ *
+ * @return string[]
+ */
+ public function getErrors(): array;
+}
diff -ruN a/Model/Quote/Item/Repository.php b/Model/Quote/Item/Repository.php
--- a/Model/Quote/Item/Repository.php 2026-03-25 06:07:15.312693187 +0000
+++ b/Model/Quote/Item/Repository.php 2026-03-25 06:07:54.824186444 +0000
@@ -49,24 +49,33 @@
private $cartItemOptionsProcessor;
/**
+ * @var CartItemValidatorInterface
+ */
+ private $cartItemValidator;
+
+ /**
* @param CartRepositoryInterface $quoteRepository
* @param ProductRepositoryInterface $productRepository
* @param CartItemInterfaceFactory $itemDataFactory
* @param CartItemOptionsProcessor $cartItemOptionsProcessor
* @param CartItemProcessorInterface[] $cartItemProcessors
+ * @param CartItemValidatorInterface|null $cartItemValidator
*/
public function __construct(
CartRepositoryInterface $quoteRepository,
ProductRepositoryInterface $productRepository,
CartItemInterfaceFactory $itemDataFactory,
CartItemOptionsProcessor $cartItemOptionsProcessor,
- array $cartItemProcessors = []
+ array $cartItemProcessors = [],
+ ?CartItemValidatorInterface $cartItemValidator = null
) {
$this->quoteRepository = $quoteRepository;
$this->productRepository = $productRepository;
$this->itemDataFactory = $itemDataFactory;
$this->cartItemOptionsProcessor = $cartItemOptionsProcessor;
$this->cartItemProcessors = $cartItemProcessors;
+ $this->cartItemValidator = $cartItemValidator
+ ?: \Magento\Framework\App\ObjectManager::getInstance()->get(CartItemValidatorInterface::class);
}
/**
@@ -103,6 +112,12 @@
$quoteItems = $quote->getItems();
$quoteItems[] = $cartItem;
$quote->setItems($quoteItems);
+ $result = $this->cartItemValidator->validate($quote, $cartItem);
+ if ($result->getErrors()) {
+ throw new InputException(
+ __(join(PHP_EOL, $result->getErrors()))
+ );
+ }
$this->quoteRepository->save($quote);
$quote->collectTotals();
return $quote->getLastAddedItem();
diff -ruN a/etc/di.xml b/etc/di.xml
--- a/etc/di.xml 2026-03-25 06:07:15.313693193 +0000
+++ b/etc/di.xml 2026-03-25 06:21:36.818039394 +0000
@@ -49,6 +49,8 @@
<preference for="Magento\Quote\Model\Quote\Item\Option\ComparatorInterface" type="Magento\Quote\Model\Quote\Item\Option\Comparator"/>
<preference for="Magento\Quote\Model\Cart\ProductReaderInterface" type="Magento\Quote\Model\Cart\ProductReader"/>
<preference for="Magento\Quote\Model\CartMutexInterface" type="Magento\Quote\Model\CartMutex"/>
+ <preference for="Magento\Quote\Model\Quote\Item\CartItemValidatorInterface" type="Magento\Quote\Model\Quote\Item\CartItemValidatorChain"/>
+ <preference for="Magento\Quote\Model\Quote\Item\CartItemValidatorResultInterface" type="Magento\Quote\Model\Quote\Item\CartItemValidatorResult"/>
<type name="Magento\Webapi\Controller\Rest\ParamsOverrider">
<arguments>
<argument name="paramOverriders" xsi:type="array">
@@ -149,4 +151,14 @@
<type name="Magento\Quote\Api\CartRepositoryInterface">
<plugin name="quoteValidateOrderId" type="Magento\Quote\Plugin\ValidateQuoteOrigOrder"/>
</type>
+ <type name="Magento\Quote\Model\Quote\Item\Repository">
+ <arguments>
+ <argument name="cartItemValidator" xsi:type="object">Magento\Quote\Model\Quote\Item\CartItemValidatorChainOnSave</argument>
+ </arguments>
+ </type>
+ <virtualType name="Magento\Quote\Model\Quote\Item\CartItemValidatorChainOnSave" type="Magento\Quote\Model\Quote\Item\CartItemValidatorChain">
+ <arguments>
+ <argument name="breakChainOnFailure" xsi:type="boolean">true</argument>
+ </arguments>
+ </virtualType>
</config>
diff -ruN a/etc/webapi_rest/di.xml b/etc/webapi_rest/di.xml
--- a/etc/webapi_rest/di.xml 2026-03-25 06:07:15.314693198 +0000
+++ b/etc/webapi_rest/di.xml 2026-03-25 06:21:46.380141756 +0000
@@ -20,4 +20,12 @@
<plugin name="updateQuoteStoreId" type="Magento\Quote\Model\Quote\Plugin\UpdateQuoteStoreId" />
<plugin name="validateQuoteAddress" type="Magento\Quote\Plugin\QuoteAddress" />
</type>
+ <virtualType name="Magento\Quote\Model\Quote\Item\CartItemValidatorChainOnSave" type="Magento\Quote\Model\Quote\Item\CartItemValidatorChain">
+ <arguments>
+ <argument name="breakChainOnFailure" xsi:type="boolean">true</argument>
+ <argument name="validators" xsi:type="array">
+ <item name="cart_item_custom_options_validator" xsi:type="object">Magento\Quote\Model\Quote\Item\CartItemCustomOptionsValidator</item>
+ </argument>
+ </arguments>
+ </virtualType>
</config>
diff -ruN a/etc/webapi_soap/di.xml b/etc/webapi_soap/di.xml
--- a/etc/webapi_soap/di.xml 2026-03-25 06:07:15.315693203 +0000
+++ b/etc/webapi_soap/di.xml 2026-03-25 06:21:51.542196878 +0000
@@ -16,4 +16,12 @@
<type name="Magento\Quote\Model\Quote">
<plugin name="updateQuoteStoreId" type="Magento\Quote\Model\Quote\Plugin\UpdateQuoteStoreId" />
</type>
+ <virtualType name="Magento\Quote\Model\Quote\Item\CartItemValidatorChainOnSave" type="Magento\Quote\Model\Quote\Item\CartItemValidatorChain">
+ <arguments>
+ <argument name="breakChainOnFailure" xsi:type="boolean">true</argument>
+ <argument name="validators" xsi:type="array">
+ <item name="cart_item_custom_options_validator" xsi:type="object">Magento\Quote\Model\Quote\Item\CartItemCustomOptionsValidator</item>
+ </argument>
+ </arguments>
+ </virtualType>
</config>
diff --color -ruN quote-patch-orig/Model/Quote/Item/CartItemCustomOptionsValidator.php quote-patch/Model/Quote/Item/CartItemCustomOptionsValidator.php
--- a/Model/Quote/Item/CartItemCustomOptionsValidator.php 2026-03-23 14:03:46.923027014 +0000
+++ b/Model/Quote/Item/CartItemCustomOptionsValidator.php 2026-03-23 14:05:22.371854512 +0000
@@ -0,0 +1,76 @@
+<?php
+/**
+ * Copyright 2025 Adobe
+ * All Rights Reserved.
+ */
+
+declare(strict_types=1);
+
+namespace Magento\Quote\Model\Quote\Item;
+
+use Magento\Catalog\Api\Data\ProductCustomOptionInterface;
+use Magento\Catalog\Api\ProductRepositoryInterface;
+use Magento\Framework\Exception\NoSuchEntityException;
+use Magento\Quote\Api\Data\CartInterface;
+use Magento\Quote\Api\Data\CartItemInterface;
+
+/**
+ * Cart item custom options validator.
+ */
+class CartItemCustomOptionsValidator implements CartItemValidatorInterface
+{
+ /**
+ * @param ProductRepositoryInterface $productRepository
+ */
+ public function __construct(
+ private readonly ProductRepositoryInterface $productRepository
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function validate(CartInterface $quote, CartItemInterface $cartItem): CartItemValidatorResultInterface
+ {
+ $errors = [];
+ $customOptions = $cartItem->getProductOption()
+ ?->getExtensionAttributes()
+ ?->getCustomOptions();
+
+ if (empty($customOptions)) {
+ return new CartItemValidatorResult($errors);
+ }
+
+ try {
+ $product = $this->productRepository->get($cartItem->getSku(), false, $quote->getStoreId());
+ } catch (NoSuchEntityException) {
+ return new CartItemValidatorResult($errors);
+ }
+
+ if (!$product->getHasOptions()) {
+ return new CartItemValidatorResult($errors);
+ }
+
+ $productOptions = [];
+ foreach ($product->getOptions() as $option) {
+ $productOptions[$option->getOptionId()] = $option;
+ }
+
+ foreach ($customOptions as $customOption) {
+ $optionId = $customOption->getOptionId();
+ $productOption = $productOptions[$optionId] ?? null;
+
+ if ($customOption->getExtensionAttributes()?->getFileInfo()
+ && $productOption
+ && $productOption->getType() !== ProductCustomOptionInterface::OPTION_TYPE_FILE
+ ) {
+ $errors[] = (string)__(
+ "Option '%1' does not support file uploads.",
+ $productOption->getTitle()
+ );
+ }
+ }
+
+ return new CartItemValidatorResult($errors);
+ }
+}
diff --color -ruN quote-patch-orig/Model/Quote/Item/CartItemValidatorChain.php quote-patch/Model/Quote/Item/CartItemValidatorChain.php
--- a/Model/Quote/Item/CartItemValidatorChain.php 2026-03-23 14:03:46.923027014 +0000
+++ b/Model/Quote/Item/CartItemValidatorChain.php 2026-03-23 14:05:16.092799930 +0000
@@ -0,0 +1,46 @@
+<?php
+/**
+ * Copyright 2025 Adobe
+ * All Rights Reserved.
+ */
+
+declare(strict_types=1);
+
+namespace Magento\Quote\Model\Quote\Item;
+
+use Magento\Quote\Api\Data\CartInterface;
+use Magento\Quote\Api\Data\CartItemInterface;
+
+/**
+ * Cart item validator chain.
+ */
+class CartItemValidatorChain implements CartItemValidatorInterface
+{
+ /**
+ * @param CartItemValidatorInterface[] $validators
+ * @param bool $breakChainOnFailure
+ */
+ public function __construct(
+ private readonly array $validators = [],
+ private readonly bool $breakChainOnFailure = false
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function validate(CartInterface $quote, CartItemInterface $cartItem): CartItemValidatorResultInterface
+ {
+ $errors = [];
+ foreach ($this->validators as $validator) {
+ $result = $validator->validate($quote, $cartItem);
+ if ($result->getErrors()) {
+ $errors = array_merge($errors, $result->getErrors());
+ if ($this->breakChainOnFailure) {
+ break;
+ }
+ }
+ }
+ return new CartItemValidatorResult($errors);
+ }
+}
diff --color -ruN quote-patch-orig/Model/Quote/Item/CartItemValidatorInterface.php quote-patch/Model/Quote/Item/CartItemValidatorInterface.php
--- a/Model/Quote/Item/CartItemValidatorInterface.php 2026-03-23 14:03:46.923027014 +0000
+++ b/Model/Quote/Item/CartItemValidatorInterface.php 2026-03-23 14:05:09.320741087 +0000
@@ -0,0 +1,27 @@
+<?php
+/**
+ * Copyright 2025 Adobe
+ * All Rights Reserved.
+ */
+
+declare(strict_types=1);
+
+namespace Magento\Quote\Model\Quote\Item;
+
+use Magento\Quote\Api\Data\CartInterface;
+use Magento\Quote\Api\Data\CartItemInterface;
+
+/**
+ * Cart item validator interface.
+ */
+interface CartItemValidatorInterface
+{
+ /**
+ * Validate cart item
+ *
+ * @param CartInterface $quote
+ * @param CartItemInterface $cartItem
+ * @return CartItemValidatorResultInterface
+ */
+ public function validate(CartInterface $quote, CartItemInterface $cartItem): CartItemValidatorResultInterface;
+}
diff --color -ruN quote-patch-orig/Model/Quote/Item/CartItemValidatorResult.php quote-patch/Model/Quote/Item/CartItemValidatorResult.php
--- a/Model/Quote/Item/CartItemValidatorResult.php 2026-03-23 14:03:46.923027014 +0000
+++ b/Model/Quote/Item/CartItemValidatorResult.php 2026-03-23 14:05:12.435768152 +0000
@@ -0,0 +1,31 @@
+<?php
+/**
+ * Copyright 2025 Adobe
+ * All Rights Reserved.
+ */
+
+declare(strict_types=1);
+
+namespace Magento\Quote\Model\Quote\Item;
+
+/**
+ * Cart item validator result.
+ */
+class CartItemValidatorResult implements CartItemValidatorResultInterface
+{
+ /**
+ * @param string[] $errors
+ */
+ public function __construct(
+ private readonly array $errors = []
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getErrors(): array
+ {
+ return $this->errors;
+ }
+}
diff --color -ruN quote-patch-orig/Model/Quote/Item/CartItemValidatorResultInterface.php quote-patch/Model/Quote/Item/CartItemValidatorResultInterface.php
--- a/Model/Quote/Item/CartItemValidatorResultInterface.php 2026-03-23 14:03:46.923027014 +0000
+++ b/Model/Quote/Item/CartItemValidatorResultInterface.php 2026-03-23 14:05:10.641752565 +0000
@@ -0,0 +1,22 @@
+<?php
+/**
+ * Copyright 2025 Adobe
+ * All Rights Reserved.
+ */
+
+declare(strict_types=1);
+
+namespace Magento\Quote\Model\Quote\Item;
+
+/**
+ * Cart item validator result interface.
+ */
+interface CartItemValidatorResultInterface
+{
+ /**
+ * Get errors
+ *
+ * @return string[]
+ */
+ public function getErrors(): array;
+}
diff --color -ruN quote-patch-orig/Model/Quote/Item/Repository.php quote-patch/Model/Quote/Item/Repository.php
--- a/Model/Quote/Item/Repository.php 2026-03-23 14:03:46.918026971 +0000
+++ b/Model/Quote/Item/Repository.php 2026-03-23 14:05:47.569073747 +0000
@@ -57,12 +57,18 @@
private ?QuoteMutexInterface $quoteMutex;
/**
+ * @var ?CartItemValidatorInterface
+ */
+ private ?CartItemValidatorInterface $cartItemValidator;
+
+ /**
* @param CartRepositoryInterface $quoteRepository
* @param ProductRepositoryInterface $productRepository
* @param CartItemInterfaceFactory $itemDataFactory
* @param CartItemOptionsProcessor $cartItemOptionsProcessor
* @param CartItemProcessorInterface[] $cartItemProcessors
* @param QuoteMutexInterface|null $quoteMutex
+ * @param CartItemValidatorInterface|null $cartItemValidator
*/
public function __construct(
CartRepositoryInterface $quoteRepository,
@@ -70,7 +76,8 @@
CartItemInterfaceFactory $itemDataFactory,
CartItemOptionsProcessor $cartItemOptionsProcessor,
array $cartItemProcessors = [],
- ?QuoteMutexInterface $quoteMutex = null
+ ?QuoteMutexInterface $quoteMutex = null,
+ ?CartItemValidatorInterface $cartItemValidator = null
) {
$this->quoteRepository = $quoteRepository;
$this->productRepository = $productRepository;
@@ -78,6 +85,7 @@
$this->cartItemOptionsProcessor = $cartItemOptionsProcessor;
$this->cartItemProcessors = $cartItemProcessors;
$this->quoteMutex = $quoteMutex ?: ObjectManager::getInstance()->get(QuoteMutexInterface::class);
+ $this->cartItemValidator = $cartItemValidator ?: ObjectManager::getInstance()->get(CartItemValidatorInterface::class);
}
/**
@@ -136,6 +144,12 @@
$quoteItems = $quote->getItems();
$quoteItems[] = $cartItem;
$quote->setItems($quoteItems);
+ $result = $this->cartItemValidator->validate($quote, $cartItem);
+ if ($result->getErrors()) {
+ throw new InputException(
+ __(join(PHP_EOL, $result->getErrors()))
+ );
+ }
$this->quoteRepository->save($quote);
$quote->collectTotals();
diff --color -ruN quote-patch-orig/etc/di.xml quote-patch/etc/di.xml
--- a/etc/di.xml 2026-03-23 14:03:46.920026988 +0000
+++ b/etc/di.xml 2026-03-23 14:06:28.644431808 +0000
@@ -48,6 +48,8 @@
<preference for="Magento\Quote\Model\CartMutexInterface" type="Magento\Quote\Model\CartMutex"/>
<preference for="Magento\Quote\Model\CartAddressMutexInterface" type="Magento\Quote\Model\CartAddressMutex"/>
<preference for="Magento\Quote\Model\Quote\Item\Option\ComparatorInterface" type="Magento\Quote\Model\Quote\Item\Option\Comparator"/>
+ <preference for="Magento\Quote\Model\Quote\Item\CartItemValidatorInterface" type="Magento\Quote\Model\Quote\Item\CartItemValidatorChain"/>
+ <preference for="Magento\Quote\Model\Quote\Item\CartItemValidatorResultInterface" type="Magento\Quote\Model\Quote\Item\CartItemValidatorResult"/>
<preference for="Magento\Quote\Model\Cart\ProductReaderInterface" type="Magento\Quote\Model\Cart\ProductReader"/>
<type name="Magento\Webapi\Controller\Rest\ParamsOverrider">
<arguments>
@@ -165,8 +167,14 @@
<type name="Magento\Quote\Model\Quote\Item\Repository">
<arguments>
<argument name="quoteMutex" xsi:type="object">Magento\Quote\Model\QuoteIdMutex</argument>
+ <argument name="cartItemValidator" xsi:type="object">Magento\Quote\Model\Quote\Item\CartItemValidatorChainOnSave</argument>
</arguments>
</type>
+ <virtualType name="Magento\Quote\Model\Quote\Item\CartItemValidatorChainOnSave" type="Magento\Quote\Model\Quote\Item\CartItemValidatorChain">
+ <arguments>
+ <argument name="breakChainOnFailure" xsi:type="boolean">true</argument>
+ </arguments>
+ </virtualType>
<preference for="Magento\Quote\Api\ErrorInterface" type="Magento\Quote\Model\Cart\Data\Error" />
<type name="Magento\Quote\Model\Cart\AddProductsToCartError">
<arguments>
diff --color -ruN quote-patch-orig/etc/webapi_rest/di.xml quote-patch/etc/webapi_rest/di.xml
--- a/etc/webapi_rest/di.xml 2026-03-23 14:03:46.921026997 +0000
+++ b/etc/webapi_rest/di.xml 2026-03-23 14:06:42.587553520 +0000
@@ -23,4 +23,12 @@
<type name="Magento\Quote\Model\QuoteValidator">
<plugin name="error_redirect_processor" type="Magento\Quote\Plugin\Webapi\Model\ErrorRedirectProcessor" />
</type>
+ <virtualType name="Magento\Quote\Model\Quote\Item\CartItemValidatorChainOnSave" type="Magento\Quote\Model\Quote\Item\CartItemValidatorChain">
+ <arguments>
+ <argument name="breakChainOnFailure" xsi:type="boolean">true</argument>
+ <argument name="validators" xsi:type="array">
+ <item name="cart_item_custom_options_validator" xsi:type="object">Magento\Quote\Model\Quote\Item\CartItemCustomOptionsValidator</item>
+ </argument>
+ </arguments>
+ </virtualType>
</config>
diff --color -ruN quote-patch-orig/etc/webapi_soap/di.xml quote-patch/etc/webapi_soap/di.xml
--- a/etc/webapi_soap/di.xml 2026-03-23 14:03:46.922027005 +0000
+++ b/etc/webapi_soap/di.xml 2026-03-23 14:06:55.180663498 +0000
@@ -16,4 +16,12 @@
<type name="Magento\Quote\Model\Quote">
<plugin name="updateQuoteStoreId" type="Magento\Quote\Model\Quote\Plugin\UpdateQuoteStoreId" />
</type>
+ <virtualType name="Magento\Quote\Model\Quote\Item\CartItemValidatorChainOnSave" type="Magento\Quote\Model\Quote\Item\CartItemValidatorChain">
+ <arguments>
+ <argument name="breakChainOnFailure" xsi:type="boolean">true</argument>
+ <argument name="validators" xsi:type="array">
+ <item name="cart_item_custom_options_validator" xsi:type="object">Magento\Quote\Model\Quote\Item\CartItemCustomOptionsValidator</item>
+ </argument>
+ </arguments>
+ </virtualType>
</config>
@joesken

joesken commented Mar 31, 2026

Copy link
Copy Markdown

Just wanted to say thank you for taking the time and effort to pull these out and provide them as such, much appreciated.

@hryvinskyi

Copy link
Copy Markdown
Author

@joesken I'm glad I could help! Thanks for the kind words

@mpchadwick

Copy link
Copy Markdown

Just noting at this point, I think these patches have some conflicts with the APSB26-73 update.

@joesken

joesken commented Jul 17, 2026

Copy link
Copy Markdown

Yes, that patch has conflicts with the APSB26-73 update in the part that patches
./vendor/magento/module-quote/etc/di.xml and ./vendor/magento/module-quote/etc/webapi_rest/di.xml.

For us it happened on Magento 2.4.6 and 2.4.7.

We patched APSB26-73 to make it work after applying the MageOS patches (Can't attach them unfortunately).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment