Full Page Cache hole punching in Magento 2

Full Page Cache hole punching in Magento 2

FPC is your friend, not your enemy...

Magento is and has always been a bit slow and resource-intensive application. One of the best ways to ensure best possible loading times for the visitors is to use full page cache - preferably Varnish - but even disk cache speeds up the experience by orders of magnitude.

In summary, full page cache serves the visitors a pre-generated version of the site, meaning that Magento itself doesn't have to generate HTML but it can be instantly served by FPC instead. This is great but can be a bit limiting when it comes to situations in which you want to introduce some customizations that should be different for different customers.

At first, marking such elements as non-cacheable could seem like a good enough solution. However, it's going to make the whole page related to such layout change non-cacheable. After all, FPC returns a complete generated site and cannot deliver 80% of static HTML and then somehow fetch missing blocks from Magento.

Mind that the same thing applies to default.xml. If you mark any block (even one that’s being used only on a single page) as non-cacheable, nothing at all is going to be cached since default.xml applies to layouts on every page. But fear not, there are ways to work around such issues.

...however, sometimes you need to punch (through) him


If you want to display different versions of a page to different user groups then you can use the X-Magento-Vary cookie. It is one of the main factors that take part in resolving which page FPC should return to a user.

It means that by controlling the value of this cookie you can show one version of a page to one group of your customers and a second one to another group. All this while still using the same URL and without any URL parameters hacks. Just remember not to use this solution when you want to display user-specific data - generating a separate cache for every user doesn't seem reasonable.

The \Magento\Framework\App\Http\Context class is responsible for handling this cookie and you have a bunch of public methods that you can plug into. You will most likely want to create a before plugin for getVaryString() in which, by calling setValue() method on your subject, you can change the cookie value by any logic you want.

If you are interested in how it works under the hood, you can find usage of the Context::getVaryString() method in the base response class' \Magento\Framework\App\Response\Http:sendVary() method.

"But hey, I've never used the sendVary() method, should I fix all my responses now?" - you ask? The answer is “no”, as long as you have the stock Magento_PageCache module enabled.

One of its plugins, \Magento\PageCache\Model\App\Response\HttpPlugin for already mentioned \Magento\Framework\App\Response\Http class' sendResponse() method takes care of this for you by automatically calling sendVary(). Such an elegant solution!

More information about the topic, as well as a sample plugin, can be found in the official documentation 

Private Content

In situations when you need to show user-specific content, you have two options.

The first one is to disable full page cache on required pages. In the stock Magento 2 installation, you can find this approach on customer pages or in the checkout.

The second option is serving static pages from your cache and fetching blocks that should be visitor-specific with javascript, punching through FPC, and getting fresh data straight from the CMS itself. The Magento team created a mechanism that makes it really easy to implement such functionality and they call it Private Content or Customer Data.

To create a private block, you need to create a knockout template, add a UI component, some XML-s, and a PHP class responsible for returning the data. But stop screaming and don't close your browser just yet because it's easier to implement it than you would expect and the amount of required JavaScript can be minimal.

Let’s begin with the PHP class. The mechanism requires you to implement \Magento\Customer\CustomerData\SectionSourceInterface which is just about providing a public class getSectionData() that should return an associative array with your data. Nothing scary here.

1 2 3 4 5 6 7 8 9 10 11 12 <?php namespace Magently\PrivateBlock\CustomerData; use Magento\Customer\CustomerData\SectionSourceInterface; class TheMostSecret implements SectionSourceInterface { public function getSectionData() { return ['timestamp' => date_timestamp_get(date_create())]; } }

Now, let's get into Magento developer’s favorite, di.xml. Here we only have to name our secret data and tell which class is responsible for handling it.

1 2 3 4 5 6 7 8 9 10 11 12 13 <?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> <type name="Magento\Customer\CustomerData\SectionPoolInterface"> <arguments> <argument name="sectionSourceMap" xsi:type="array"> <item name="super-secret" xsi:type="string"> Magently\PrivateBlock\CustomerData\TheMostSecret </item> </argument> </arguments> </type> </config>

Staying within the scope of configuration XMLs, let's tell Magento when our data should be fetched. We'll do it with sections.xml. Since private content is going to be saved in the visitor's browser’s localStorage, we need to tell Magento when the data should be updated.

Mind that since GET requests are cached by FPC, we need to attach to a POST (or PUT) method. Let's say we want to update our data on currency change:

1 2 3 4 5 6 7 <?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Customer:etc/sections.xsd"> <action name="directory/currency/switch"> <section name="super-secret"/> </action> </config>

When it comes to preparing data, we have one last thing to take care of - our uiComponent.

1 2 3 4 5 6 7 8 9 10 11 define([ 'uiComponent', 'Magento_Customer/js/customer-data' ], function (Component, customerData) { return Component.extend({ initialize: function () { this._super(); this.privateVariable = customerData.get('super-secret'); } }); });

Now, the only thing left is to add a standard block with a template to our page. Hopefully, you already know how to add a block to a page with layout.xml, so let's skip this part and just present a sample template with knockout bindings.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <div data-bind="scope: 'test'"> <span> <?= __('Currency changed at: ') ?> </span> <span data-bind="text: privateVariable().timestamp"> <?= __("waiting...") ?> </span> </div> <script type="text/x-magento-init"> { "*": { "Magento_Ui/js/core/app": { "components": { "test": { "component": "Magently_PrivateBlock/js/view/test" } } } } } </script>

And that's everything you need to create a basic private block. You probably have some questions, so for more information, I invite you again to the official documentation that should answer most of your problems related to the topic. I only want you to remember that even though we call this private data, you should never trust it and always verify whatever comes from the browser to your app.

Even if it’s your code that added value in the customer's localStorage, there is nothing preventing a user from changing it on their own. Also, you should not save data that's too private there, since you cannot guarantee it's going to be safe. One XSS vulnerability and the data can be compromised.

Keep calm and work with the cache

As you can see, there are some ways to work around the most common issues with content served by full page cache, so get back to your project and rethink all these cacheable="false" directives that you used in your layout XML files. Remember - the faster your frontend is, the happier and more engaged your customers are!