Cache context and page variations in Magento 2

The Magento page cache library contains a simple PHP reverse proxy that enables page content caching. There are two types of distinguished content: private and public.

The “private” content is being served for each user separately and represents the personalized data of a customer. E.g. shopping cart, wishlist, customer addresses, notification messages. This data should not be cached on the server side, and should not be shown to more than one user. The private content is stored on the client’s side by Customer Data JS component (Magento_Customer/js/customer-data.js). However, this is a topic for another blog post. Today we will focus on the second content type – public content, or rather the cacheable content and its variations in terms of customer grouping.

Reverse proxies serve “public” content to more than one user. Magento 2 uses HTTP context variables in order to make each cached content by URL totally unique. Context variables enable the Magento application to serve different content for the same URL based on:

  • Customer group and whether a customer is logged in or not (\Magento\Customer\Model\App\Action\ContextPlugin::beforeDispatch);
  • Selected store and currency (\Magento\Store\App\Action\Plugin\Context::beforeDispatch).

We can also add our own context variable depending on a particular task. It can be accomplished by implementing a Magento 2 plugin for \Magento\Framework\App\Http\Context::getVaryString method. This method is intended to prepare the cache key per request.

Note: the context variables should not be used for a specific customer based on customer unique individual data (e.g. id, email, VAT), otherwise the cache storage can be overflowed.

Let’s say we need to implement a custom feature which provides different content based on some custom attribute which allows to point out a group of customer. For example the customer’s country.

First of all, we need to create a new customer attribute. The data install script is the following.

<?php
/* File: app/code/Atwix/CustomCacheContext/Setup/InstallData.php */

namespace Atwix\CustomCacheContext\Setup;

use Exception;
use Magento\Customer\Model\Customer;
use Magento\Customer\Model\ResourceModel\Address\Attribute\Source\Country as CountrySourceModel;
use Magento\Customer\Model\ResourceModel\Attribute as AttributeResourceModel;
use Magento\Customer\Setup\CustomerSetup;
use Magento\Customer\Setup\CustomerSetupFactory;
use Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface;
use Magento\Eav\Model\Entity\Attribute\Set as AttributeSet;
use Magento\Eav\Model\Entity\Attribute\SetFactory as AttributeSetFactory;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Setup\InstallDataInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\ModuleDataSetupInterface;

/**
 * Class InstallData
 * @codeCoverageIgnore
 */
class InstallData implements InstallDataInterface
{
    /**
     * Default Country Customer Attribute
     */
    const ATTRIBUTE_CUSTOMER_DEFAULT_COUNTRY = 'default_country_id';

    /**
     * Customer Setup Factory
     *
     * @var CustomerSetupFactory
     */
    protected $customerSetupFactory;

    /**
     * Attribute Set Factory
     *
     * @var AttributeSetFactory
     */
    protected $attributeSetFactory;

    /**
     * Attribute Resource Model
     *
     * @var AttributeResourceModel
     */
    protected $attributeResourceModel;

    /**
     * CreateCustomerAttributeService constructor
     *
     * @param CustomerSetupFactory $customerSetupFactory
     * @param AttributeSetFactory $attributeSetFactory
     * @param AttributeResourceModel $attributeResourceModel
     */
    public function __construct(
        CustomerSetupFactory $customerSetupFactory,
        AttributeSetFactory $attributeSetFactory,
        AttributeResourceModel $attributeResourceModel
    ) {
        $this->customerSetupFactory = $customerSetupFactory;
        $this->attributeSetFactory = $attributeSetFactory;
        $this->attributeResourceModel = $attributeResourceModel;
    }

    /**
     * Installs data for a module
     *
     * @param ModuleDataSetupInterface $setup
     * @param ModuleContextInterface $context
     *
     * @throws Exception
     * @throws LocalizedException
     */
    public function install(ModuleDataSetupInterface $setup, ModuleContextInterface $context)
    {
        $setup->startSetup();
        /** @var CustomerSetup $customerSetup */
        $customerSetup = $this->customerSetupFactory->create(['setup' => $setup]);
        $customerEntity = $customerSetup->getEavConfig()->getEntityType(Customer::ENTITY);
        $attributeSetId = $customerEntity->getDefaultAttributeSetId();
        /** @var AttributeSet $attributeSet */
        $attributeSet = $this->attributeSetFactory->create();
        $attributeGroupId = $attributeSet->getDefaultGroupId($attributeSetId);

        $customerSetup->addAttribute(
            Customer::ENTITY,
            self::ATTRIBUTE_CUSTOMER_DEFAULT_COUNTRY,
            [
                'type' => 'varchar',
                'label' => 'Country',
                'input' => 'select',
                'global' => ScopedAttributeInterface::SCOPE_STORE,
                'source' => CountrySourceModel::class,
                'default' => '',
                'visible' => true,
                'required' => true,
                'unique' => false,
                'system' => false,
                'user_defined' => true,
                'is_used_in_grid' => false,
                'is_visible_in_grid' => false,
                'is_filterable_in_grid' => false,
            ]
        );

        $countryAttribute = $customerSetup->getEavConfig()->getAttribute(
            Customer::ENTITY,
            self::ATTRIBUTE_CUSTOMER_DEFAULT_COUNTRY
        );

        $countryAttribute->addData(
            [
                'attribute_set_id'   => $attributeSetId,
                'attribute_group_id' => $attributeGroupId,
                'sort_order' => 100,
                'used_in_forms' => [
                    'adminhtml_customer',
                    'customer_account_create',
                    'customer_account_edit',
                ]
            ]
        );

        $this->attributeResourceModel->save($countryAttribute);
        $setup->endSetup();
    }
}

Now, we can use the values of default_country_id attribute in order to define a new context variable. For this purpose, we will create a Magento 2 plugin for Magento\Framework\App\Http\Context.

<?xml version="1.0"?>
<!-- File: app/code/Atwix/CustomCacheContext/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Framework\App\Http\Context">
        <plugin name="atwix_country_cache_context_plugin"
                type="Atwix\CustomCacheContext\Plugin\CustomerCountryCacheContextPlugin" />
    </type>
</config>

The implementation of the plugin class is as follows.

<?php
/* File: app/code/Atwix/CustomCacheContext/Plugin/CustomerCountryCacheContextPlugin.php */

namespace Atwix\CustomCacheContext\Plugin;

use Magento\Customer\Api\Data\CustomerInterface;
use Magento\Customer\Model\Session;
use Magento\Framework\Api\AttributeInterface;
use Magento\Framework\App\Http\Context as HttpContext;

/**
 * Class CustomerCountryCacheContextPlugin
 */
class CustomerCountryCacheContextPlugin
{
    /**
     * Default Country Customer Attribute
     */
    const ATTRIBUTE_CUSTOMER_DEFAULT_COUNTRY = 'default_country_id';

    /**
     * Customer group cache context
     */
    const COUNTRY_CONTEXT_GROUP = 'customer_county';

    /**
     * Customer group cache context
     */
    const NOT_LOGGED_IN_CUSTOMER_COUNTRY_VALUE = '';

    /**
     * Customer Session
     *
     * @var Session
     */
    protected $customerSession;

    /**
     * CustomerCountryCacheContextPlugin constructor
     *
     * @param Session $customerSession
     */
    public function __construct(Session $customerSession)
    {
        $this->customerSession = $customerSession;
    }

    /**
     * Add Customer Country cache context for caching purposes
     *
     * @param HttpContext $subject
     *
     * @return array
     */
    public function beforeGetVaryString(HttpContext $subject)
    {
        $customerData = $this->customerSession->getCustomerData();

        if (!($customerData instanceof CustomerInterface)) {
            $subject->setValue(
                self::COUNTRY_CONTEXT_GROUP,
                self::NOT_LOGGED_IN_CUSTOMER_COUNTRY_VALUE,
                self::NOT_LOGGED_IN_CUSTOMER_COUNTRY_VALUE
            );

            return [];
        }

        $countryCodeAttribute = $customerData->getCustomAttribute(
            self::ATTRIBUTE_CUSTOMER_DEFAULT_COUNTRY
        );

        if (!($countryCodeAttribute instanceof AttributeInterface)) {
            $subject->setValue(
                self::COUNTRY_CONTEXT_GROUP,
                self::NOT_LOGGED_IN_CUSTOMER_COUNTRY_VALUE,
                self::NOT_LOGGED_IN_CUSTOMER_COUNTRY_VALUE
            );

            return [];
        }

        $countryCode = $countryCodeAttribute->getValue();
        $subject->setValue(self::COUNTRY_CONTEXT_GROUP, $countryCode, self::NOT_LOGGED_IN_CUSTOMER_COUNTRY_VALUE);

        return [];
    }
}

We use the Magento 2 before plugin for setVaryString method, so the value of custom context variable will be added right before the page cache identifier generation and assignment.

In this way, we ensure that customers with different default_country_id values will get the different content of cacheable pages. This is a very useful feature of Magento 2 in case of shared content caching. Hope you like it as well! :)

The full module’s source code can be found in Atwix_CustomCacheContext GIT repository.

Thanks for reading!