Project Overview
One of Magento’s biggest strengths is its multi-store functionality. The ability to override data and configuration for a given website or store while inheriting higher scope values is particularly powerful, allowing merchants and developers to create tailored experiences without the overhead of managing completely different stores.
Although very powerful and flexible, managing many system configuration values for many different websites and stores can make it easy to overlook when a value is overridden at a more specific scope.
A similar problem exists when managing a large codebase with many subclasses. It’s sometimes easy to overlook when a method is overridden by a subclass, wasting development time. I’ve always been a fan of PHP Storm’s indication when a method is overridden by a subclass — the visibility that a simple icon provides is incredible.
While working on a particularly multi-store-heavy site, I found myself similarly wasting time when I would fail to notice (or check) if a system configuration value was overridden at a more specific scope, and wished I had a similar visual cue. This prompted me to explore creating a module which would add this type of functionality to the system configuration.
Due to the recent release of Magento 2 RC2, I also wanted to explore porting this module to Magento 2.
Magento 1 Module
Requirements
Conceptually, this is a relatively simple addition. When each field is rendered, compare its value at the current scope to the values at more specific scopes. If the field’s value is different at any more specific scopes, add a visual indication to the right-most column with details and links to the overriding scopes.
Implementation
As with most modules which inject new functionality into existing areas of Magento, there are two big parts to the approach: find the most suitable point to modify or interact with core code, then use that interaction point to call custom logic which implements new functionality.
Interaction Point
After several debugging sessions, I found that there are no templates or layout XML for individual system configuration fields — we’ll be dealing with straight PHP for this modification. In particular, I need a to find a point in the core where (a) enough information about the field is present to perform value comparisons and (b) I can also influence the output of the scope label column.
In general, Mage_Adminhtml_Block_System_Config_Form::initFields()
loops over elements of a group, and encapsulates the entire rendering of a field row. However, it doesn’t dispatch any events, nor is it well structured to subclass without copying the method contents forward — in fact, a rewrite of this method would require copying forward more than 200 lines of very logic-dense code. As such, it is an undesirable interaction point.
Mage_Adminhtml_Block_System_Config_Form::getScopeLabel()
, however, seems promising; a simple method which accepts a field element, determines its scope label, and then returns the scope label as a string. A subclass could add content to the returned string without having to copy any code forward. Unfortunately, the element parameter does not contain sufficient information to get the field’s values, rendering this interaction point unusable for our purposes.
After much effort to find a “surgical” interaction point, I came to realize that, unfortunately, it would be necessary to rewrite Mage_Adminhtml_Block_System_Config_Form
and copy its entire initFields()
method forward. In an effort to avoid any more direct modifications than necessary, the only change I made to this method was to add an event before the element is rendered. This event, in turn, is observed by the module to effect the desired new functionality.
NOTE: this very compatibility concern was quickly manifested — on EE 1.10 (at least), the config scope hints are shown and accurate, but the UI input near them is always blank. After some investigation, this is due to differences in initFields() between the two versions. At this point, reconciling the different version is prohibitively time-consuming.
Custom Logic Implementation
Having determined an interaction point, the actual new functionality must be implemented. After some consideration, I decided to structure the encapsulated value override detection according to the following logic, given a config path and current context scope and scope ID.
- Get field’s precise value at context scope.
- Generate scope tree below current scope.
- Iterate over scope tree, getting values at each precise scope.
- If the iteration scope’s value does not match the context scope’s value, then add it to an array of overriding scopes.
- Finally, format output HTML string based on override array.
The most complicated part of this logic was reviewing the plethora of system config methods available through the core, and determining which is the best one to use for each of these steps. In particular, getting the precise value of a path at a given scope required using different methods from different models depending on the actual scope. That is, if a given field is set at the default, website, and store values, I couldn’t simply use Mage::getStoreConfig()
to get the website value — this method expects a store instance, and passing in one of the website’s child stores would return the store’s value, which may differ from the website value. In addition, since it was necessary to use lower level methods to get precise values, I had to ensure that fields with a backend model were handled correctly.
However, after some trial and error to find core methods to robustly handle these issues, the resulting logic was complete.
<?php class EW_ConfigScopeHints_Helper_Data extends Mage_Core_Helper_Abstract { const PROFILER_KEY = 'EW_ConfigScopeHints'; /** * Get default store ID. * Abstracted so it can be improved if default store * ID not always 0. * * @return int */ protected function _getDefaultStoreId() { return 0; } /** * Get scopes tree in following form: * * array('websites' => array ( * website id => array('stores' => array of store ids), * ... * ) * ) * * @return array */ public function getScopeTree() { $tree = array('websites' => array()); $websites = Mage::app()->getWebsites(); /* @var $website Mage_Core_Model_Website */ foreach($websites as $website) { $tree['websites'][$website->getId()] = array('stores' => array()); /* @var $store Mage_Core_Model_Store */ foreach($website->getStores() as $store) { $tree['websites'][$website->getId()]['stores'][] = $store->getId(); } } return $tree; } /** * Get config node value after processing with backend model * * @param Mage_Core_Model_Config_Element $node * @param $path * @return string */ protected function _getProcessedValue(Mage_Core_Model_Config_Element $node, $path) { $value = (string)$node; if (!empty($node['backend_model']) && !empty($value)) { $backend = Mage::getModel((string)$node['backend_model']); $backend->setPath($path)->setValue($value)->afterLoad(); $value = $backend->getValue(); } return $value; } /** * Get current value by scope and scope ID, * or null if none could be found. * * @param $path * @param $contextScope * @param $contextScopeId * @return mixed|null|string * @throws Mage_Core_Exception */ protected function _getConfigValue($path, $contextScope, $contextScopeId) { $currentValue = null; switch($contextScope) { case 'websites': $code = Mage::app()->getWebsite($contextScopeId)->getCode(); $node = Mage::getConfig()->getNode('websites/'.$code.'/'.$path); $currentValue = !$node ? '' : $this->_getProcessedValue($node, $path); break; case 'default': $node = Mage::getConfig()->getNode('default/' . $path); $currentValue = !$node ? '' : $this->_getProcessedValue($node, $path); break; case 'stores': $currentValue = Mage::app()->getStore($contextScopeId)->getConfig($path); break; } return $currentValue; } /** * Get scopes where value of config at path is overridden. * Returned in form of * array( array('scope' => overridden scope, 'scope_id' => overridden scope id), ...) * * @param $path * @param $contextScope * @param $contextScopeId * @return array */ public function getOverridenLevels($path, $contextScope, $contextScopeId) { $contextScopeId = $contextScopeId ?: $this->_getDefaultStoreId(); $currentValue = $this->_getConfigValue($path, $contextScope, $contextScopeId); if(is_null($currentValue)) { return array(); //something is off, let's bail gracefully. } $tree = $this->getScopeTree(); $overridden = array(); switch($contextScope) { case 'websites': $stores = array_values($tree['websites'][$contextScopeId]['stores']); foreach($stores as $storeId) { $value = $this->_getConfigValue($path, 'stores', $storeId); if($value != $currentValue) { $overridden[] = array( 'scope' => 'store', 'scope_id' => $storeId ); } } break; case 'default': foreach($tree['websites'] as $websiteId => $website) { $websiteValue = $this->_getConfigValue($path, 'websites', $websiteId); if($websiteValue != $currentValue) { $overridden[] = array( 'scope' => 'website', 'scope_id' => $websiteId ); } foreach($website['stores'] as $storeId) { $value = $this->_getConfigValue($path, 'stores', $storeId); if($value != $currentValue && $value != $websiteValue) { $overridden[] = array( 'scope' => 'store', 'scope_id' => $storeId ); } } } break; } return $overridden; } /** * Format overridden scopes for output * * @param array $overridden * @return string */ public function formatOverriddenScopes(array $overridden) { $title = $this->__('This setting is overridden at a more specific scope. Click for details.'); $formatted = '<a class="overridden-hint-list-toggle" title="'. $title .'" href="#">'. $title .'</a>'. '<ul class="overridden-hint-list">'; foreach($overridden as $overriddenScope) { $scope = $overriddenScope['scope']; $scopeId = $overriddenScope['scope_id']; $scopeLabel = $scopeId; $url = '#'; $section = Mage::app()->getRequest()->getParam('section'); //grrr. switch($scope) { case 'website': $url = Mage::getModel('adminhtml/url')->getUrl( '*/*/*', array( 'section'=>$section, 'website'=>Mage::app()->getWebsite($scopeId)->getCode() ) ); $scopeLabel = sprintf( 'website <a href="%s">%s</a>', $url, Mage::app()->getWebsite($scopeId)->getName() ); break; case 'store': $store = Mage::app()->getStore($scopeId); $website = $store->getWebsite(); $url = Mage::getModel('adminhtml/url')->getUrl( '*/*/*', array( 'section' => $section, 'website' => $website->getCode(), 'store' => $store->getCode() ) ); $scopeLabel = sprintf( 'store view <a href="%s">%s</a>', $url, $website->getName() . ' / ' . $store->getName() ); break; } $formatted .= "<li class='$scope'>Overridden on $scopeLabel</li>"; } $formatted .= '</ul>'; return $formatted; } }
Results
After adding a few styles and a tiny bit of Prototype UI javascript, the desired functionality works like a charm — fields overridden at more specific scopes now clearly indicate this fact.
Packaging
As with any other module distributed on GitHub, the standard packaging system is to create a modman config file to allow developers and merchants automated installations.
app/code/community/EW/ConfigScopeHints app/etc/modules/EW_ConfigScopeHints.xml app/design/adminhtml/default/default/layout/ew/configscopehints.xml skin/adminhtml/default/default/ew/configscopehints ## translations app/locale/en_US/EW_ConfigScopeHints.csv app/locale/es_ES/EW_ConfigScopeHints.csv
Magento 2 Module
Requirements
Magento 2 not only provides a much improved and better-designed framework on which to build, but also the opportunity to rethink existing approaches and requirements. However, given that this project was specifically intended to start as the concrete exercise of a straight port, the requirements were exactly the same as the Magento 1 module.
NOTE: this port was implemented and tested on Magento 2 version 0.74.0-beta4.
Implementation
This module has the same approach considerations as its Magento 1 inspiration: find the best way to interact with the core, then use that interaction point to implement custom functionality to accomplish the requirements. Due to significant architectural differences in M1 and M2, the M2 module would need to be restructured, pulling in a much code from the M1 version as possible.
Interaction Point
The biggest liability of the M1 module is the fact that, despite using a proper extension mechanism (block rewrite), a huge amount of logic had to be copied from the core and modified to create an interaction point, creating a significant surface area for compatibility issues.
In an effort to improve the interaction point in M2, I revisited the oh-so-close MagentoConfigBlockSystemConfigForm::getScopeLabel()
method. Unlike M1, the M2 version of this method accepts a more specific object as its parameter, which handily does have all the required information to get its value. This small change means that the surface area of the interaction point (along with the possibility for compatibility issues) is significantly reduced.
Using my Magento customization bag of tricks, honed over the last three years and spanning as many certifications, I examined the options to interact with the getScopeLabel()
method. As always, my first consideration is to determine which events, if any, can be observed. Alas, as is too often the case, there are no events which can be used.
In the absence of a usable event, my next consideration is to determine if the interaction point can be created by subclassing Form
and overriding the getScopeLabel()
method. Subclassing the Form
class is still not ideal, as it would require a rewrite to take effect system-wide. While no code will be copied forward, this rewrite would conflict with any other module which also rewrites this class.
If only Magento could have anticipated my requirements and dispatched an event which passed the method’s arguments and also provided the opportunity to append to the method’s return value …
Fortunately, Magento 2, introduces the concept of plugins — a brand new extension mechanism which provides this exact functionality. Instead of depending on developers to anticipate all possible future customizations and dispatch corresponding events, plugins can intercept method calls before or after invocation, providing extremely flexible global interaction points.
In order to accomplish this module’s requirements, it actually needs to observe both before (to have original arguments present) and after (to append to the return value) getScopeLabel()
invocation. This is supported by the around
plugin listener type.
di.xml
<?xml version="1.0"?> <config xmlns_xsi="http://www.w3.org/2001/XMLSchema-instance" xsi_noNamespaceSchemaLocation="../../../../../lib/internal/Magento/Framework/ObjectManager/etc/config.xsd"> <type name="MagentoConfigBlockSystemConfigForm"> <plugin name="configScopeHints" type="EWConfigScopeHintsModelPlugin" sortOrder="100" /> </type> </config>
Plugin.php
<?php namespace EWConfigScopeHintsModel; use MagentoConfigModelConfigStructureElementField; use MagentoFrameworkPhrase; class Plugin { /** @var EWConfigScopeHintsHelperData */ protected $_helper; /** * @param EWConfigScopeHintsHelperData $helper */ public function __construct(EWConfigScopeHintsHelperData $helper) { $this->_helper = $helper; } /** * Intercept core config form block getScopeLabel() method * to add additional override hints. * * @see MagentoConfigBlockSystemConfigForm::getScopeLabel() * @param MagentoConfigBlockSystemConfigForm $form * @param callable $getScopeLabel * @param Field $field * @return Phrase */ public function aroundGetScopeLabel(MagentoConfigBlockSystemConfigForm $form, Closure $getScopeLabel, Field $field) { $currentScopeId = null; switch($form->getScope()) { case 'websites': $currentScopeId = $form->getWebsiteCode(); break; case 'stores': $currentScopeId = $form->getStoreCode(); break; } $overriddenLevels = $this->_helper->getOverridenLevels($field->getPath(), $form->getScope(), $currentScopeId); /* @var $returnPhrase Phrase */ $labelPhrase = $getScopeLabel($field); if(!empty($overriddenLevels)) { $scopeHintText = $labelPhrase . $this->_helper->formatOverriddenScopes($form, $overriddenLevels); // create new phrase, now that constituent strings are translated individually $labelPhrase = new Phrase($scopeHintText, $labelPhrase->getArguments()); } return $labelPhrase; } }
With this small amount of code, the module has a perfect interaction point, without having to rewrite the block — the chances of compatibility issues have never been lower.
Custom Logic Implementation
With a slick interaction point established, the M1 custom logic must be ported to M2. This was surprisingly easy — the only significant changes were that the config models must be injected rather than pulled off the old global Mage
class and the logic had to be updated to use the M2 config methods to find config values for comparison.
Due to the robust dependency injection support in M2, I had to do nothing special to get instances of the config models — simply adding them to the constructor was sufficient. Adjusting for the new config model methods was also trivial. In fact, it was easy to find new methods which greatly simplified the previously unintuitive mechanisms for finding the precise value of a config field at a given scope. All in all, porting the module’s business logic didn’t take more than a half hour or so.
<?php namespace EWConfigScopeHintsHelper; use MagentoStoreModelWebsite; use MagentoStoreModelStore; class Data extends MagentoFrameworkAppHelperAbstractHelper { /** @var MagentoFrameworkAppHelperContext */ protected $_context; /** @var MagentoStoreModelStoreManagerInterface */ protected $_storeManger; /** * @param MagentoFrameworkAppHelperContext $context * @param MagentoStoreModelStoreManagerInterface $storeManager */ public function __construct( MagentoFrameworkAppHelperContext $context, MagentoStoreModelStoreManagerInterface $storeManager ) { $this->_storeManger = $storeManager; $this->_context = $context; } /** * Gets store tree in a format easily walked over * for config path value comparison * * @return array */ public function getScopeTree() { $tree = array('websites' => array()); $websites = $this->_storeManger->getWebsites(); /* @var $website Website */ foreach($websites as $website) { $tree['websites'][$website->getId()] = array('stores' => array()); /* @var $store Store */ foreach($website->getStores() as $store) { $tree['websites'][$website->getId()]['stores'][] = $store->getId(); } } return $tree; } /** * Wrapper method to get config value at path, scope, and scope code provided * * @param $path * @param $contextScope * @param $contextScopeId * @return mixed */ protected function _getConfigValue($path, $contextScope, $contextScopeId) { return $this->_context->getScopeConfig()->getValue($path, $contextScope, $contextScopeId); } /** * Gets array of scopes and scope IDs where path value is different * than supplied context scope and context scope ID. * If no lower-level scopes override the value, return empty array. * * @param $path * @param $contextScope * @param $contextScopeId * @return array */ public function getOverridenLevels($path, $contextScope, $contextScopeId) { $tree = $this->getScopeTree(); $currentValue = $this->_getConfigValue($path, $contextScope, $contextScopeId); if(is_null($currentValue)) { return array(); //something is off, let's bail gracefully. } $overridden = array(); switch($contextScope) { case 'websites': $stores = array_values($tree['websites'][$contextScopeId]['stores']); foreach($stores as $storeId) { $value = $this->_getConfigValue($path, 'stores', $storeId); if($value != $currentValue) { $overridden[] = array( 'scope' => 'store', 'scope_id' => $storeId ); } } break; case 'default': foreach($tree['websites'] as $websiteId => $website) { $websiteValue = $this->_getConfigValue($path, 'websites', $websiteId); if($websiteValue != $currentValue) { $overridden[] = array( 'scope' => 'website', 'scope_id' => $websiteId ); } foreach($website['stores'] as $storeId) { $value = $this->_getConfigValue($path, 'stores', $storeId); if($value != $currentValue && $value != $websiteValue) { $overridden[] = array( 'scope' => 'store', 'scope_id' => $storeId ); } } } break; } return $overridden; } /** * Get HTML output for override hint UI * * @param MagentoConfigBlockSystemConfigForm $form * @param array $overridden * @return string */ public function formatOverriddenScopes(MagentoConfigBlockSystemConfigForm $form, array $overridden) { $title = __('This setting is overridden at a more specific scope. Click for details.'); $formatted = '<a class="overridden-hint-list-toggle" title="'. $title .'" href="#"><span>'. $title .'</span></a>'. '<ul class="overridden-hint-list">'; foreach($overridden as $overriddenScope) { $scope = $overriddenScope['scope']; $scopeId = $overriddenScope['scope_id']; $scopeLabel = $scopeId; $url = '#'; $section = $form->getSectionCode(); switch($scope) { case 'website': $url = $this->_context->getUrlBuilder()->getUrl( '*/*/*', array( 'section'=>$section, 'website'=>$scopeId ) ); $scopeLabel = sprintf( 'website <a href="%s">%s</a>', $url, $this->_storeManger->getWebsite($scopeId)->getName() ); break; case 'store': $store = $this->_storeManger->getStore($scopeId); $website = $store->getWebsite(); $url = $this->_context->getUrlBuilder()->getUrl( '*/*/*', array( 'section' => $section, 'website' => $website->getCode(), 'store' => $store->getCode() ) ); $scopeLabel = sprintf( 'store view <a href="%s">%s</a>', $url, $website->getName() . ' / ' . $store->getName() ); break; } $formatted .= "<li class='$scope'>Overridden on $scopeLabel</li>"; } $formatted .= '</ul>'; return $formatted; } }
Results
Porting over the tiny bit of CSS into LESS and porting the tiny bit of UI javascript from Prototype to jQuery took no time at all, and before I knew it I had successfully ported my M1 module to M2.
Packaging
With the minor detail of actually accomplishing the requirements out of the way, it was time to package up the module and make it easy for merchants and developers to install. Due to Magento 2’s standardization on composer, this was very straightforward.
The module was committed to GitHub at the module root level, and a simple composer.json file added.
{ "name": "ericthehacker/magento2-configscopehints", "description": "Magento 2 store config override hints module", "require": { "magento/magento-composer-installer": "*" }, "type": "magento2-module", "version": "2.0", "extra": { "map": [ [ "*", "EW/ConfigScopeHints" ] ] }, "authors": [ { "name": "Eric Wiese", "homepage": "https://ericwie.se/", "role": "Developer" } ] }
With this file in place, merchants and developers can easily install the module by running commands similar to the following from their Magento 2 root directory.
$ composer config repositories.magento2-configscopehints vcs https://github.com/ericthehacker/magento2-configscopehints.git # add repo
$ composer require ericthehacker/magento2-configscopehints # require module
$ php -f bin/magento module:enable EW_ConfigScopeHints
$ php -f bin/magento setup:upgrade
Pro Tips and Lessons Learned
Although I consider this port to have gone very smoothly, there were a few roadblocks for which the solution was not obvious. These are the gotchas and hiccups that I encountered and their solutions.
Outdated / missing documentation
When following Magento’s official developer documentation (as opposed to the old documentation which, annoyingly, seems to outrank the new docs in Google search results), a module’s basic declaration is of the following form.
<config>
<module name="Namespace_Module" schema_version="2.0.0">
</module>
</config>
Following that snippet, I created my module shell, only to be greeted with the following error message.
Attribute 'setup_version' is missing for module 'EW_ConfigScopeHints'.
As stated in the error message, the schema_version
attribute should actually be setup_version
. Not a big change, but it’s kind of concerning that such a fundamental snippet is factually incorrect in the documentation — especially since this will be each new developer’s first stop.
At the very least, however, the presence of that error message demonstrated that my module was, indeed, being loaded. However, after fixing the attribute name, I couldn’t seem to get any of my module’s functionality to work. Struggling with out of date and missing documentation, I assumed that my dependency injection XML was simply incorrect. After much trial and error and debugging, I realized my module simply wasn’t enabled.
Although the documentation does give the following note, it doesn’t say how to actually enable a module — I’m doing development and I don’t have a deployment configuration file!
The enabled/disabled flag for a module is no longer set within a module; it is controlled by the deployment configuration file, with enabled=1, disabled=0. This is controlled by administrators and integrators, not by module developers.
With no actual documentation to guide me, I eventually figured out that modules must be enabled in app/etc/config.php
. However, editing this file manually is discouraged, so the following commands (currently) are the best way to enable a module manually.
$ php -f bin/magento module:enable EW_ConfigScopeHints
$ php -f bin/magento setup:upgrade
Since I think these documentation gaps are critical, I’ve created a pull request to update the documentation.
Developer Mode
Looking up errors in var/report
gets old pretty quickly, so one of the first things I needed to figure out is how to display errors by enabling developer mode. Fortunately, it’s really straightforward — the MAGE_MODE
environmental variable must be set to the value developer
, as shown in the following examples.
- Apache config:
SetEnv MAGE_MODE developer
- Bash .bash_profile:
export MAGE_MODE=developer
Compiled Assets
Due to Magento 2 “compilation” of interceptors and views, I frequently ended up with Magento running out of date code. Having to work with this type of issue was not entirely unforeseen, but it took a few tries to figure out the exact directories to flush. For back end compiled assets (such as interceptors), deleting the contents of var/generation
will cause them to be regenerated. For front end assets (such as LESS files), var/view_preprocessed
should be flushed.
Where to go from here
As mentioned earlier, Magento 2 is not only an updated framework but also an opportunity to rethink existing solutions. Although this module provides value as a straight port, there is room to improve its adherence to Magento 2 conventions.
For example, in my opinion, the admin UI should be rethought — the tiny icon looks out of place on M2 and having all the content in the last store config field column doesn’t work as well, especially considering the semi-responsive nature of the M2 admin.
Another possible step forward, if the UI is updated to be more consistent with Magento 2 admin, and given the fact that Magento is accepting community contributions via GitHub, is that this module might be worth porting it to the core and issuing a pull request to be included in Magento 2 core.
Where to get it
Interested in using one of these modules or just want to review the entire source code? They’re freely available and liberally licensed at the following GitHub links.
- Magento 1: https://github.com/ericthehacker/magento-configscopehints
- Magento 2: https://github.com/ericthehacker/magento2-configscopehints
Use it wisely!