Magento 2’s Customer Attribute (And Address) Bizarreness

Adding customer attributes to be editable only for admin accounts, you would think would be a straightforward process. Well let me tell you that Magento 2 has other ideas!

Firstly, adding to forms. You’ll notice that just adding attributes to either adminhtml_customer, or adminhtml_customer_address does nothing in the admin area. For some ungodly reason, to get them to show, attributes must also be added to the customer_account_edit and customer_address_edit forms.

Great! attributes now appear in the admin area, but woe unto thee that thinks we’re done here!

Saving from the Admin Area

Hahaha, expecting them to save… just like that? Fool. Make sure you’ve also set the system key of your attribute to false, otherwise Magento will just ignore them.

They’ll need adding to an attribute set and group for this to work correctly; typically this is just the default of each for the entity type.

So now we should be done… right? RIGHT?! Well, young urchin, try updating a customer account information or address from the frontend of your store. What’s that? Hahaha, yes, Magento just obliterated your custom customer attribute values. Serves you right for being so optimistic.

Now, you’ll notice, that even though we’ve added the attributes to the customer_account_edit and customer_address_edit forms to get them to show in the admin area, which foolishly in my mind should have no effect there, that do not show in the frontend.

No, if you want them to appear for customers, you’ll have to do more than add the attribute to a form (guffaws). But, for this example, we don’t want them to show in the frontend and certainly don’t want the data to be sent into the flaming abyss by a customer’s saving of them.

The fix, in true Magento style, is to set the visible key to false when creating the attribute, which persists to the customer_eav_attribute‘s is_visible column. Yes… visible prevents Magento from trying to save the data from the frontend. Cue manic laughter from the Magento Devs as they revel in our pain.

Recap

customer_account_edit and customer_address_edit forms – shows the field in the admin area.
is_system – Set to false for Magento to save the value from the admin area.
Add to attribute set and group – Also required to save from the admin area.
visible – Prevents Magento from trying to save the values from the frontend’s customer account section.

I’m not 100% sure what the adminhtml_customer and adminhtml_customer_address forms are actually used for anymore, but I’ll keep them for a laugh, just in case the Magento devs change their minds.

A code snippet:

protected function createCustomerAttributes(
    \Magento\Eav\Setup\EavSetup $eavSetup,
    ModuleDataSetupInterface $setup
) {
    $attributes = [];

    $customerSetup = $this->customerSetupFactory->create(['setup' => $setup]);
    $customerEntity = $customerSetup->getEavConfig()->getEntityType(\Magento\Customer\Model\Customer::ENTITY);
    $attributeSetId = $customerEntity->getDefaultAttributeSetId();
    $attributeSet = $this->attributeSetFactory->create();
    $attributeGroupId = $attributeSet->getDefaultGroupId($attributeSetId);

    // ... Add other attributes here 

    $attributes['attribute_code'] = [
        'type'     => 'varchar',
        'label'    => 'Attribute label',
        'input'    => 'text',
        'visible'  => false,
        'system' => false,
        'required' => false,
        'user_defined' => true,
        'position' => 290
    ];

    foreach ($attributes as $code => $attribute) {

        $eavSetup->addAttribute(
            \Magento\Customer\Model\Customer::ENTITY,
            $code,
            $attribute
        );

        $createdAttribute = $this->eavConfig->getAttribute(
            \Magento\Customer\Model\Customer::ENTITY,
            $code
        );

        $createdAttribute->setData('used_in_forms', ['customer_account_edit', 'adminhtml_customer'])
            ->setData('attribute_set_id', $attributeSetId)
            ->setData('attribute_group_id', $attributeGroupId)
            ->save();
    }
}

Come on Magento 2, we need more hoops to jump through than that! It’s like it’s not even trying to be frustrating some days.

Create Attributes In Magento 2

Creating attributes in Magento 2 is very similar to Magento 1. Magento 2, for the most part is smart enough to ignore the creation of attributes which already exist, and will instead update them. This is all well and good, unless your attributes have options in them. In which case, you will need to check whether the attribute exists already or Magento will duplicate option values on subsequent runs of your installer or updater.

The fix for this is to check whether the attribute already exists before attempting to create it, however this causes another issue. Magento’s EAVConfig class caches attributes after a the getAttribute method is called – so this cache needs cleaning if we need to set other data on the attribute after creation, such as the forms it needs to exist in.

The following is an example to create a customer address attribute, taken from an installer class where $this->eavConfig is an instance of \Magento\Eav\Model\Config:

protected function createCustomerAddressAttributes(
    \Magento\Eav\Setup\EavSetup $eavSetup,
    ModuleDataSetupInterface $setup
) {

    $customerAddressSetup = $this->customerSetupFactory->create(['setup' => $setup]);
    $customerAddressEntity = $customerAddressSetup->getEavConfig()->getEntityType(
        \Magento\Customer\Api\AddressMetadataInterface::ENTITY_TYPE_ADDRESS
    );
    $attributeSetId = $customerAddressEntity->getDefaultAttributeSetId();
    $attributeSet = $this->attributeSetFactory->create();
    $attributeGroupId = $attributeSet->getDefaultGroupId($attributeSetId);

    $attributes = [];

    $attributes['attribute_code'] = [
        'type'     => 'text',
        'label'    => 'Attribute Label',
        'input'    => 'textarea',
        'visible'  => true,
        'required' => false,
        'user_defined' => true,
        'position' => 220
    ];

    foreach ($attributes as $code => $attribute) {
        $this->eavConfig->clear();
        // Don't create the attribute if it already exists
        $attributeCheck = $this->eavConfig->getAttribute(
            \Magento\Customer\Api\AddressMetadataInterface::ENTITY_TYPE_ADDRESS,
            $code
        );
        
        if ($attributeCheck->getAttributeId()) {
            continue;
        }

        // Stop Magento from loading the cached attribute with no Id.
        $this->eavConfig->clear();

        $attribute['system'] = 0;

        $eavSetup->addAttribute(
            \Magento\Customer\Api\AddressMetadataInterface::ENTITY_TYPE_ADDRESS,
            $code,
            $attribute
        );

        $createdAttribute = $this->eavConfig->getAttribute(
            \Magento\Customer\Api\AddressMetadataInterface::ENTITY_TYPE_ADDRESS,
            $code
        );

        $createdAttribute->setData('used_in_forms', ['adminhtml_customer_address', 'customer_address_edit'])
            ->setData('attribute_set_id', $attributeSetId)
            ->setData('attribute_group_id', $attributeGroupId)
            ->save();
    }

    return $this;        
}

If we didn’t have the $this->eavConfig->clear(); clear in there, the installer would use the cached version of the attribute when performing our initial $attributeCheck, and the final save would try and create the attribute again.

Fixing slow reindexes in Magento 2 & MariaDb

We recently noticed some discrepancies when indexing catalog_category_product on Mariadb when compared to Mysql 5.7. The indexer would take around 13 minutes on MariaDb, compared to around 3 seconds on MySql. To add more confusion to the mix, MariaDb was running on a powerful staging server, and MySQL running on a lowly MacBook Pro development machine.

After much head-scratching and career path questioning, the issue seemed to be related to the following statement in Magento\Catalog\Model\Indexer\Category\Product\Action\Full.php

$this->connection->query(
    $this->connection->insertFromSelect(
        $resultSelect,
        $this->tableMaintainer->getMainTmpTable((int)$store->getId()),
        $columns,
        AdapterInterface::INSERT_ON_DUPLICATE
    )
);

This is executed in the reindexCategoriesBySelect method, which creates a temporary table to work with when regenerating the index for a particular store. It turns out that MariaDb’s temporary table usage woefully bad when large amounts of data are being inserted. This appears to be related when aria_used_for_temp_tables is set to ON, a value which can only be changed by recompiling MariaDb. See here and here.

The fix, without switching database engines, or recompiling MariaDb, is to adjust Magento’s batchRowsCount to a lower number so that the database isn’t dealing with as many temporary table inserts at a time. Magento provide config settings for this value for all of their indexers which use temporary tables, so adjust this value for whichever indexer is giving slow performance. For our case, changing the default value from 100000 (the default) to 500 brought the indexer time down from 13 minutes to 6 seconds. The following, added to di.xml was the panacea for our case

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Catalog\Model\Indexer\Category\Product\Action\Full">
        <arguments>
            <argument name="batchRowsCount" xsi:type="number">500</argument>
        </arguments>
    </type>
</config>

Magento’s documentation on use of this value can be found here, which states

“You can experiment to determine the ideal batch size. In general, halving the batch size can decrease the indexer execution time”.

Yup, experiment indeed with this value, YMMV! Happy indexing!

Magento 2’s uiRegistry Debugging

On the frontend of Magento 2 components are constructed hierarchically. Their names are derived from a concatenation of their parent names. Looking at the checkout_index_index.xml, we can see that the checkout component contains a horrendous amount of config.

To make debugging of this gargantuan hellspawned rat’s nest easier, we can use the uiRegistry object in our browser’s console.

If we would like to get a particular object and we know the full concatenated name of the item, we can simply use something akin to the following;

requirejs('uiRegistry').get("checkout.steps.shipping-step.shippingAddress");

If however, we would like to get a uiComponent using a property name, we can instead use the get method as a query. In the example above, if we only knew the script location of the uiComponent in question, we could instead perform

requirejs('uiRegistry').get("component = Magento_Checkout/js/view/shipping");

We can also pass in a callback method as the second parameter, where the item(s) returned are passed in as parameters to that function.

Getting all registered uiComponents

The get method also allows us to pass in a callback function as the first parameter, instead of a query. This will pass all items sequentially through our callback, allowing us to see exactly what is registered;

requirejs('uiRegistry').get(function(item){
    console.log(item.name, item);
});

Do this on the checkout and prepare to have an exorcist level of data vomited into your poor, unsuspecting console.

Related Magento DevDoc

Stopping Magento 2 Redirecting from the Checkout

Sometimes, we want to add debug information when submitting Magento’s checkout to see what exactly is going on in the backend. Adding any debug information to the payload of Magento’s payment-information call will cause a redirect back to the cart page. This will render any information we’ve output as unobtainable even when using “Preserve Log” in Chrome Dev Tools. To stop this redirect, we can temporarily comment out the following in module-checkout/view/frontend/web/js/view/payment/default.js in the placeOrder method

if (self.redirectAfterPlaceOrder) {
    redirectOnSuccessAction.execute();
}

collectTotals in Magento 2

If we were to have a custom quote object which we were adding items to, we can re-calculate the totals in the cart using the following method.

$shippingAddress = $preorderQuote->getShippingAddress();
$shippingAddress->unsetData('cached_items_all');            
$quote->setTotalsCollectedFlag(false)->collectTotals();
$this->quoteRepository->save($quote);

We unset the cached item on the quote address object to force Magento to re-load the items for the address. This is necessary as Magento will sometimes not update this automatically, leading to an incorrect zero value quote and quote items.

Installing Magento 2 Using Composer

Install Magento

Standard Install

This will install Magento with no settings present – you will enter these from visiting your site’s URL.

composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition <directory>

Setup Install

This will install Magento pre-populated with settings, so that you won’t have to enter them in the setup process.

php bin/magento setup:install \
    --db-host=127.0.0.1 \
    --db-name=magento2 \
    --db-user=root \
    --db-password=password123 \
    --base-url=http://www.example.com/ \
    --admin-user=admin \
    --admin-firstname=Admin \
    --admin-lastname=User \
    --admin-email=test@example.com \
    --admin-password=password123

bin/magento cron:run
bin/magento cron:run
bin/magento setup:static-content:deploy
bin/magento deploy:mode:set developer
rm -rf var/cache

If you are prompted to enter a username and password, you can retrieve or create these by logging into magentocommerce.com;

  • Click the ‘Connect’ Tab
  • Click ‘Secure Keys’ from the left navigation
  • If you don’t have a secure key listed, enter a name and click ‘Generate new’.
  • The username you need to enter is the Public Key, and the password is the Private Key.

Change the Directory Permissions

find var vendor pub/static pub/media app/etc -type f -exec chmod g+w {} \;
find var vendor pub/static pub/media app/etc -type d -exec chmod g+w {} \;
chmod u+x bin/magento

Complete Installation 

Visiting your website’s URL should walk you through the rest of the installation process. This step will be omitted if the ‘Setup Install’ section above was followed

Installing Sample Data 

The following will install Magento’s sample data with an increased memory limit, as it will tend to fail without it.

php -d memory_limit=2G bin/magento sampledata:deploy

Run Upgrade

bin/magento setup:upgrade

Registering a Module

Location

For the purpose of this example, we’ll make our module in app/code/.

Create a namespace directory – E.g. ‘llapgoch’. Within that should be another directory which is typically the name of the module; for this example we’ll use ‘developertoolbar’. The structure of this folder is quite similar to Magento 1’s module structure.

Within our developertoolbar directory, we need to create a registration.php file which Magento will look for in order to register our module. This would be a rough equivalent of the old module registration stub files back in Magento 1, which in this case would be Llapgoch_Developertoolbar.xml.

Add the following to the registration.php file to tell Magento about our new module:

\Magento\Framework\Component\ComponentRegistrar::register(
    \Magento\Framework\Component\ComponentRegistrar::MODULE,
    'Llapgoch_Developertoolbar',
    __DIR__
);

The Register Method’s Parameters

  1. Type– Either MODULE , LIBRARY, LANGUAGE, or THEME which are constants within the ComponentRegistrar class.
  2. Name
  3. Location – Typically, we’ll use PHP’s magic constant __DIR__ to tell Magento that our module exists in the same place as our registration.php file

Check The Module’s Status

To check Magento can actually see the module, navigate to the bin folder in terminal and run:

./magento module:status

Your module should be listed at the bottom, in “List of disabled modules”.

Enable the Module

Run the following from terminal which will enable your module:

./magento module:enable Llapgoch_Developertoolbar

And that’s it! We’ve got a module registered with Magento. Now there’s the task of actually making it do something…