If a product in Magento® 2 doesn’t have an image, a placeholder is displayed on the front end. The store administrator can set the image for the placeholder on the Product Image Placeholders page: Stores -> Configuration -> Catalog -> Product Image Placeholders Magento. However, a bug was detected in Magento 2.2.6 that doesn’t let administrators upload their own images for placeholders. Below, we’ll examine the reason for this bug and how to fix it.
Working with Placeholders
Placeholders are configuration parameters that are set in adminhtml/system.xml in the Magento_Catalog module. Their description looks like this:
...
<group id="placeholder" translate="label" sortOrder="300" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Product Image Placeholders</label>
<clone_fields>1</clone_fields>
<clone_model>Magento\Catalog\Model\Config\CatalogClone\Media\Image</clone_model>
<field id="placeholder" type="image" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1">
<backend_model>Magento\Config\Model\Config\Backend\Image</backend_model>
<upload_dir config="system/filesystem/media" scope_info="1">catalog/product/placeholder</upload_dir>
<base_url type="media" scope_info="1">catalog/product/placeholder</base_url>
</field>
</group>
....
This group of parameters is defined as such because a product could have any number of image attributes, including those implemented by different third-party modules.The cloning model Magento\Catalog\Model\Config\CatalogClone\Media\Image will look as follows:
class Image extends \Magento\Framework\App\Config\Value
{
/**
* Eav config
*
* @var \Magento\Eav\Model\Config
*/
protected $_eavConfig;
/**
* Attribute collection factory
*
* @var \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory
*/
protected $_attributeCollectionFactory;
/**
* @param \Magento\Framework\Model\Context $context
* @param \Magento\Framework\Registry $registry
* @param \Magento\Framework\App\Config\ScopeConfigInterface $config
* @param \Magento\Framework\App\Cache\TypeListInterface $cacheTypeList
* @param \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory $attributeCollectionFactory
* @param \Magento\Eav\Model\Config $eavConfig
* @param \Magento\Framework\Model\ResourceModel\AbstractResource $resource
* @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection
* @param array $data
*/
public function __construct(
\Magento\Framework\Model\Context $context,
\Magento\Framework\Registry $registry,
\Magento\Framework\App\Config\ScopeConfigInterface $config,
\Magento\Framework\App\Cache\TypeListInterface $cacheTypeList,
\Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory $attributeCollectionFactory,
\Magento\Eav\Model\Config $eavConfig,
\Magento\Framework\Model\ResourceModel\AbstractResource $resource = null,
\Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null,
array $data = []
) {
$this->_attributeCollectionFactory = $attributeCollectionFactory;
$this->_eavConfig = $eavConfig;
parent::__construct($context, $registry, $config, $cacheTypeList, $resource, $resourceCollection, $data);
}
/**
* Get fields prefixes
*
* @return array
*/
public function getPrefixes()
{
// use cached eav config
$entityTypeId = $this->_eavConfig->getEntityType(\Magento\Catalog\Model\Product::ENTITY)->getId();
/* @var $collection \Magento\Catalog\Model\ResourceModel\Product\Attribute\Collection */
$collection = $this->_attributeCollectionFactory->create();
$collection->setEntityTypeFilter($entityTypeId);
$collection->setFrontendInputTypeFilter('media_image');
$prefixes = [];
foreach ($collection as $attribute) {
/* @var $attribute \Magento\Eav\Model\Entity\Attribute */
$prefixes[] = [
'field' => $attribute->getAttributeCode() . '_',
'label' => $attribute->getFrontend()->getLabel(),
];
}
return $prefixes;
}
}
When the administrator uploads an image in the field on the configuration page and the submit form, it will be processed on the server by the Magento\Config\Controller\Adminhtml\System\Config\Save controller and will look as follows:
class Save extends AbstractConfig
{
/**
* Backend Config Model Factory
*
* @var \Magento\Config\Model\Config\Factory
*/
protected $_configFactory;
/**
* @var \Magento\Framework\Cache\FrontendInterface
*/
protected $_cache;
/**
* @var \Magento\Framework\Stdlib\StringUtils
*/
protected $string;
/**
* @param \Magento\Backend\App\Action\Context $context
* @param \Magento\Config\Model\Config\Structure $configStructure
* @param \Magento\Config\Controller\Adminhtml\System\ConfigSectionChecker $sectionChecker
* @param \Magento\Config\Model\Config\Factory $configFactory
* @param \Magento\Framework\Cache\FrontendInterface $cache
* @param \Magento\Framework\Stdlib\StringUtils $string
*/
public function __construct(
\Magento\Backend\App\Action\Context $context,
\Magento\Config\Model\Config\Structure $configStructure,
\Magento\Config\Controller\Adminhtml\System\ConfigSectionChecker $sectionChecker,
\Magento\Config\Model\Config\Factory $configFactory,
\Magento\Framework\Cache\FrontendInterface $cache,
\Magento\Framework\Stdlib\StringUtils $string
) {
parent::__construct($context, $configStructure, $sectionChecker);
$this->_configFactory = $configFactory;
$this->_cache = $cache;
$this->string = $string;
}
/**
* Get groups for save
*
* @return array|null
*/
protected function _getGroupsForSave()
{
$groups = $this->getRequest()->getPost('groups');
$files = $this->getRequest()->getFiles('groups');
if ($files && is_array($files)) {
/**
* Carefully merge $_FILES and $_POST information
* None of ' =' or 'array_merge_recursive' can do this correct
*/
foreach ($files as $groupName => $group) {
$data = $this->_processNestedGroups($group);
if (!empty($data)) {
if (!empty($groups[$groupName])) {
$groups[$groupName] = array_merge_recursive((array)$groups[$groupName], $data);
} else {
$groups[$groupName] = $data;
}
}
}
}
return $groups;
}
/**
* Process nested groups
*
* @param mixed $group
* @return array
*/
protected function _processNestedGroups($group)
{
$data = [];
if (isset($group['fields']) && is_array($group['fields'])) {
foreach ($group['fields'] as $fieldName => $field) {
if (!empty($field['value'])) {
$data['fields'][$fieldName] = ['value' => $field['value']];
}
}
}
if (isset($group['groups']) && is_array($group['groups'])) {
foreach ($group['groups'] as $groupName => $groupData) {
$nestedGroup = $this->_processNestedGroups($groupData);
if (!empty($nestedGroup)) {
$data['groups'][$groupName] = $nestedGroup;
}
}
}
return $data;
}
/**
* Custom save logic for section
*
* @return void
*/
protected function _saveSection()
{
$method = '_save' . $this->string->upperCaseWords($this->getRequest()->getParam('section'), '_', '');
if (method_exists($this, $method)) {
$this->{$method}();
}
}
/**
* Advanced save procedure
*
* @return void
*/
protected function _saveAdvanced()
{
$this->_cache->clean();
}
/**
* Save configuration
*
* @return \Magento\Backend\Model\View\Result\Redirect
*/
public function execute()
{
try {
// custom save logic
$this->_saveSection();
$section = $this->getRequest()->getParam('section');
$website = $this->getRequest()->getParam('website');
$store = $this->getRequest()->getParam('store');
$configData = [
'section' => $section,
'website' => $website,
'store' => $store,
'groups' => $this->_getGroupsForSave(),
];
/** @var \Magento\Config\Model\Config $configModel */
$configModel = $this->_configFactory->create(['data' => $configData]);
$configModel->save();
$this->messageManager->addSuccess(__('You saved the configuration.'));
} catch (\Magento\Framework\Exception\LocalizedException $e) {
$messages = explode("\n", $e->getMessage());
foreach ($messages as $message) {
$this->messageManager->addError($message);
}
} catch (\Exception $e) {
$this->messageManager->addException(
$e,
__('Something went wrong while saving this configuration:') . ' ' . $e->getMessage()
);
}
$this->_saveState($this->getRequest()->getPost('config_state'));
/** @var \Magento\Backend\Model\View\Result\Redirect $resultRedirect */
$resultRedirect = $this->resultRedirectFactory->create();
return $resultRedirect->setPath(
'adminhtml/system_config/edit',
[
'_current' => ['section', 'website', 'store'],
'_nosid' => true
]
);
}
}
As you can see, in the execute method the save is carried out in the save() method of the Magento\Config\Model\Config class. Let’s take a look at this class.
class Config extends \Magento\Framework\DataObject
{
/**
* Config data for sections
*
* @var array
*/
protected $_configData;
/**
* Event dispatcher
*
* @var \Magento\Framework\Event\ManagerInterface
*/
protected $_eventManager;
/**
* System configuration structure
*
* @var \Magento\Config\Model\Config\Structure
*/
protected $_configStructure;
/**
* Application config
*
* @var \Magento\Framework\App\Config\ScopeConfigInterface
*/
protected $_appConfig;
/**
* Global factory
*
* @var \Magento\Framework\App\Config\ScopeConfigInterface
*/
protected $_objectFactory;
/**
* TransactionFactory
*
* @var \Magento\Framework\DB\TransactionFactory
*/
protected $_transactionFactory;
/**
* Config data loader
*
* @var \Magento\Config\Model\Config\Loader
*/
protected $_configLoader;
/**
* Config data factory
*
* @var \Magento\Framework\App\Config\ValueFactory
*/
protected $_configValueFactory;
/**
* @var \Magento\Store\Model\StoreManagerInterface
*/
protected $_storeManager;
/**
* @var Config\Reader\Source\Deployed\SettingChecker
*/
private $settingChecker;
/**
* @param \Magento\Framework\App\Config\ReinitableConfigInterface $config
* @param \Magento\Framework\Event\ManagerInterface $eventManager
* @param \Magento\Config\Model\Config\Structure $configStructure
* @param \Magento\Framework\DB\TransactionFactory $transactionFactory
* @param \Magento\Config\Model\Config\Loader $configLoader
* @param \Magento\Framework\App\Config\ValueFactory $configValueFactory
* @param \Magento\Store\Model\StoreManagerInterface $storeManager
* @param Config\Reader\Source\Deployed\SettingChecker|null $settingChecker
* @param array $data
*/
public function __construct(
\Magento\Framework\App\Config\ReinitableConfigInterface $config,
\Magento\Framework\Event\ManagerInterface $eventManager,
\Magento\Config\Model\Config\Structure $configStructure,
\Magento\Framework\DB\TransactionFactory $transactionFactory,
\Magento\Config\Model\Config\Loader $configLoader,
\Magento\Framework\App\Config\ValueFactory $configValueFactory,
\Magento\Store\Model\StoreManagerInterface $storeManager,
SettingChecker $settingChecker = null,
array $data = []
) {
parent::__construct($data);
$this->_eventManager = $eventManager;
$this->_configStructure = $configStructure;
$this->_transactionFactory = $transactionFactory;
$this->_appConfig = $config;
$this->_configLoader = $configLoader;
$this->_configValueFactory = $configValueFactory;
$this->_storeManager = $storeManager;
$this->settingChecker = $settingChecker ?: ObjectManager::getInstance()->get(SettingChecker::class);
}
/**
* Save config section
* Require set: section, website, store and groups
*
* @throws \Exception
* @return $this
*/
public function save()
{
$this->initScope();
$sectionId = $this->getSection();
$groups = $this->getGroups();
if (empty($groups)) {
return $this;
}
$oldConfig = $this->_getConfig(true);
/** @var \Magento\Framework\DB\Transaction $deleteTransaction */
$deleteTransaction = $this->_transactionFactory->create();
/** @var \Magento\Framework\DB\Transaction $saveTransaction */
$saveTransaction = $this->_transactionFactory->create();
$changedPaths = [];
// Extends for old config data
$extraOldGroups = [];
foreach ($groups as $groupId => $groupData) {
$this->_processGroup(
$groupId,
$groupData,
$groups,
$sectionId,
$extraOldGroups,
$oldConfig,
$saveTransaction,
$deleteTransaction
);
$groupChangedPaths = $this->getChangedPaths($sectionId, $groupId, $groupData, $oldConfig, $extraOldGroups);
$changedPaths = \array_merge($changedPaths, $groupChangedPaths);
}
try {
$deleteTransaction->delete();
$saveTransaction->save();
// re-init configuration
$this->_appConfig->reinit();
// website and store codes can be used in event implementation, so set them as well
$this->_eventManager->dispatch(
"admin_system_config_changed_section_{$this->getSection()}",
[
'website' => $this->getWebsite(),
'store' => $this->getStore(),
'changed_paths' => $changedPaths,
]
);
} catch (\Exception $e) {
// re-init configuration
$this->_appConfig->reinit();
throw $e;
}
return $this;
}
/**
* Map field name if they were cloned
*
* @param Group $group
* @param string $fieldId
* @return string
*/
private function getOriginalFieldId(Group $group, string $fieldId): string
{
if ($group->shouldCloneFields()) {
$cloneModel = $group->getCloneModel();
/** @var \Magento\Config\Model\Config\Structure\Element\Field $field */
foreach ($group->getChildren() as $field) {
foreach ($cloneModel->getPrefixes() as $prefix) {
if ($prefix['field'] . $field->getId() === $fieldId) {
$fieldId = $field->getId();
break(2);
}
}
}
}
return $fieldId;
}
/**
* Get field object
*
* @param string $sectionId
* @param string $groupId
* @param string $fieldId
* @return Field
*/
private function getField(string $sectionId, string $groupId, string $fieldId): Field
{
/** @var \Magento\Config\Model\Config\Structure\Element\Group $group */
$group = $this->_configStructure->getElement($sectionId . '/' . $groupId);
$fieldPath = $group->getPath() . '/' . $this->getOriginalFieldId($group, $fieldId);
$field = $this->_configStructure->getElement($fieldPath);
return $field;
}
/**
* Get field path
*
* @param Field $field
* @param array &$oldConfig Need for compatibility with _processGroup()
* @param array &$extraOldGroups Need for compatibility with _processGroup()
* @return string
*/
private function getFieldPath(Field $field, array &$oldConfig, array &$extraOldGroups): string
{
$path = $field->getGroupPath() . '/' . $field->getId();
/**
* Look for custom defined field path
*/
$configPath = $field->getConfigPath();
if ($configPath && strrpos($configPath, '/') > 0) {
// Extend old data with specified section group
$configGroupPath = substr($configPath, 0, strrpos($configPath, '/'));
if (!isset($extraOldGroups[$configGroupPath])) {
$oldConfig = $this->extendConfig($configGroupPath, true, $oldConfig);
$extraOldGroups[$configGroupPath] = true;
}
$path = $configPath;
}
return $path;
}
/**
* Check is config value changed
*
* @param array $oldConfig
* @param string $path
* @param array $fieldData
* @return bool
*/
private function isValueChanged(array $oldConfig, string $path, array $fieldData): bool
{
if (isset($oldConfig[$path]['value'])) {
$result = !isset($fieldData['value']) || $oldConfig[$path]['value'] !== $fieldData['value'];
} else {
$result = empty($fieldData['inherit']);
}
return $result;
}
/**
* Get changed paths
*
* @param string $sectionId
* @param string $groupId
* @param array $groupData
* @param array &$oldConfig
* @param array &$extraOldGroups
* @return array
*/
private function getChangedPaths(
string $sectionId,
string $groupId,
array $groupData,
array &$oldConfig,
array &$extraOldGroups
): array {
$changedPaths = [];
if (isset($groupData['fields'])) {
foreach ($groupData['fields'] as $fieldId => $fieldData) {
$field = $this->getField($sectionId, $groupId, $fieldId);
$path = $this->getFieldPath($field, $oldConfig, $extraOldGroups);
if ($this->isValueChanged($oldConfig, $path, $fieldData)) {
$changedPaths[] = $path;
}
}
}
if (isset($groupData['groups'])) {
$subSectionId = $sectionId . '/' . $groupId;
foreach ($groupData['groups'] as $subGroupId => $subGroupData) {
$subGroupChangedPaths = $this->getChangedPaths(
$subSectionId,
$subGroupId,
$subGroupData,
$oldConfig,
$extraOldGroups
);
$changedPaths = \array_merge($changedPaths, $subGroupChangedPaths);
}
}
return $changedPaths;
}
/**
* Process group data
*
* @param string $groupId
* @param array $groupData
* @param array $groups
* @param string $sectionPath
* @param array &$extraOldGroups
* @param array &$oldConfig
* @param \Magento\Framework\DB\Transaction $saveTransaction
* @param \Magento\Framework\DB\Transaction $deleteTransaction
* @return void
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
* @SuppressWarnings(PHPMD.NPathComplexity)
*/
protected function _processGroup(
$groupId,
array $groupData,
array $groups,
$sectionPath,
array &$extraOldGroups,
array &$oldConfig,
\Magento\Framework\DB\Transaction $saveTransaction,
\Magento\Framework\DB\Transaction $deleteTransaction
) {
$groupPath = $sectionPath . '/' . $groupId;
if (isset($groupData['fields'])) {
/** @var \Magento\Config\Model\Config\Structure\Element\Group $group */
$group = $this->_configStructure->getElement($groupPath);
// set value for group field entry by fieldname
// use extra memory
$fieldsetData = [];
foreach ($groupData['fields'] as $fieldId => $fieldData) {
$fieldsetData[$fieldId] = $fieldData['value'] ?? null;
}
foreach ($groupData['fields'] as $fieldId => $fieldData) {
$isReadOnly = $this->settingChecker->isReadOnly(
$groupPath . '/' . $fieldId,
$this->getScope(),
$this->getScopeCode()
);
if ($isReadOnly) {
continue;
}
$field = $this->getField($sectionPath, $groupId, $fieldId);
/** @var \Magento\Framework\App\Config\ValueInterface $backendModel */
$backendModel = $field->hasBackendModel()
? $field->getBackendModel()
: $this->_configValueFactory->create();
if (!isset($fieldData['value'])) {
$fieldData['value'] = null;
}
$data = [
'field' => $fieldId,
'groups' => $groups,
'group_id' => $group->getId(),
'scope' => $this->getScope(),
'scope_id' => $this->getScopeId(),
'scope_code' => $this->getScopeCode(),
'field_config' => $field->getData(),
'fieldset_data' => $fieldsetData,
];
$backendModel->addData($data);
$this->_checkSingleStoreMode($field, $backendModel);
$path = $this->getFieldPath($field, $extraOldGroups, $oldConfig);
$backendModel->setPath($path)->setValue($fieldData['value']);
$inherit = !empty($fieldData['inherit']);
if (isset($oldConfig[$path])) {
$backendModel->setConfigId($oldConfig[$path]['config_id']);
/**
* Delete config data if inherit
*/
if (!$inherit) {
$saveTransaction->addObject($backendModel);
} else {
$deleteTransaction->addObject($backendModel);
}
} elseif (!$inherit) {
$backendModel->unsConfigId();
$saveTransaction->addObject($backendModel);
}
}
}
if (isset($groupData['groups'])) {
foreach ($groupData['groups'] as $subGroupId => $subGroupData) {
$this->_processGroup(
$subGroupId,
$subGroupData,
$groups,
$groupPath,
$extraOldGroups,
$oldConfig,
$saveTransaction,
$deleteTransaction
);
}
}
}
....
}
As you can see, the save() method checks all groups of parameters sent to the server, and for each, it calls the _processGroup() method, while the final one calls the getField() method for each field.
The following happens in this method: the group of placeholders actually clones the same element for different EAV attributes that can implement the image. But at the same time, the getField method calls the getOriginalFieldId method, which returns the path catalog/placeholder/placeholder described in system.xml for any element.
This path will then be used to save the placeholder image in the core_config_data table. But this isn’t exactly what should be happening since the path to the placeholder configuration should be catalog/placeholder/image_placeholder for the default image, catalog/placeholder/small_image_placeholder for the listing image, etc.
Solution Using Plugin
This problem needs to be solved, and the best way to do that is at the backend-model level. The back-end model in our case is implemented by the class Magento\Config\Model\Config\Backend\Image, which is inherited from Magento\Config\Model\Config\Backend\File. This is inherited from \Magento\Framework\App\Config\Value, which in turn is inherited from \Magento\Framework\Model\AbstractModel. But using the model’s event handler beforeSafe won’t work since the beforeSave method is redefined in the Magento\Config\Model\Config\Backend\File class without calling the parent method. So all that’s left is to implement the plugin as follows:
<type name="Magento\Config\Model\Config\Backend\Image">
<plugin name="fix-placeholders" type="Web4pro\Defaultproduct\Model\Plugin" sortOrder="20"/>
</type>
Plugin’s class will be the following:
class Plugin
{
public function beforeBeforeSave($subject){
if(($f=$subject->getField())&&($subject->getPath()=='catalog/placeholder/placeholder')){
$subject->setPath(str_replace('placeholder/placeholder','placeholder/'.$f,$subject->getPath()));
}
return array();
}
}
With this fix, the path to each placeholder in the core_config_data table will be unique and placeholders will be saved successfully by the site administrator.