Magento 2 Template Paths

While working on a Magento 2 project yesterday, I encountered a scenario where I needed to change a block's template to be a custom template. In Magento 1, it's pretty easy to do this with the setTemplate() method:

<reference name="block_to_change">
    <action method="setTemplate">
        <template>path/to/your/template.phtml</template>
    </action>
</reference>

Magento 2 has similar functionality:

<referenceBlock name="order_shipping_view">
    <action method="setTemplate">
        <argument name="template" xsi:type="string"><![CDATA[The topic of the blog post.]]></argument>
    </action>
</referenceBlock>

You would then set the path to the template inside the <argument> node. This is a handy way to make UI-related customizations, without having to create a new block.

As any Magento developer could tell you, the path to your template in Magento 1 is pretty straightforward. Because templates are stored in the app/design/[area]/[package]/[theme]/[template] directory, you place the template anywhere inside the theme’s template directory. You then use its path, relative to the template directory, as the path in layout.

Since Magento 2 is more decoupled, it is approached a little differently. The Magento 2 module structure introduces the idea of templates inside a module’s directory (ModuleName/view/[area]/templates). Any templates your module depends on should be inside that directory. If a block’s template is in your module, the path will be relative to the ModuleName/view/[area]/templates directory.

However, if you are referencing a template for a block from another module, including core blocks (like Magento\Framework\View\Element\Template), the only path that works will be like this: ModuleName::path/to/your/template.phtml. In this example, the path/to/your/template.phtml part is relative to the view/[area]/templates directory in the module you are working with. The system won't find the template without the ModuleName:: part.

I was a bit puzzled when I found this. Why would the Magento 2 team choose an approach like this? And, what was going on in the Magento 2 core when it was attempting to locate a template?

Using PhpStorm and Xdebug to step through the process, I found the Magento\Framework\View\Filesystem::getTemplateFileName() method and quickly figured out the answer to my questions. Here's the code from that method:

 // Lines 108-126

 /**
 * Get a template file
 *
 * @param string $fileId
 * @param array $params
 * @return string|bool
 */
public function getTemplateFileName($fileId, array $params = [])
{
    list($module, $filePath) = \Magento\Framework\View\Asset\Repository::extractModule(
        $this->normalizePath($fileId)
    );
    if ($module) {
        $params['module'] = $module;
    }
    $this->_assetRepo->updateDesignParams($params);
    return $this->_templateFileResolution
        ->getFile($params['area'], $params['themeModel'], $filePath, $params['module']);
}

Let's break that down. The method accepts a variable named $fileId, which I observed to be the template path from layout (the value set in the template argument above) and an array of parameters. The $fileId variable is passed to another method: Magento\Framework\View\Asset\Repository::extractModule(). Let's have a look at that method.

//Lines 415-434

/**
 * Extract module name from specified file ID
 *
 * @param string $fileId
 * @return array
 * @throws \Magento\Framework\Exception\LocalizedException
 */
public static function extractModule($fileId)
{
    if (strpos($fileId, self::FILE_ID_SEPARATOR) === false) {
        return ['', $fileId];
    }
    $result = explode(self::FILE_ID_SEPARATOR, $fileId, 2);
    if (empty($result[0])) {
        throw new \Magento\Framework\Exception\LocalizedException(
            new \Magento\Framework\Phrase('Scope separator "::" cannot be used without scope identifier.')
        );
    }
    return [$result[0], $result[1]];
}

In the above code, the constant FILE_ID_SEPARATOR is defined as ::. At this point, the code becomes simple PHP: if the template path doesn't contain the string ::, an array with two elements is returned. The first element would be an empty string, and the second element would be the template path. If, however, the template file path has the string :: in it, it is split on that substring, and an array is returned with two elements. The first element is a string that contains the first part of the split template path. The second element contains the second part of the split template path.

At this point, you may be wondering what the returned value of that method has to do with anything. Let's look at the first method again:

list($module, $filePath) = \Magento\Framework\View\Asset\Repository::extractModule(
    $this->normalizePath($fileId)
);
if ($module) {
    $params['module'] = $module;
}

I've reposted the relevant lines here, but if you want to see the whole method, scroll up and review it.

Take a look at the first line there—the one with list(). PHP uses the list() construct to create and assign multiple variables from an array. The variable names that are passed into the parentheses will then be assigned to the elements of the array, in order. The Magento\Framework\View\Asset\Repository::extractModule() method returns an array with two elements. The first element will be either an empty string or a string containing the ModuleName part of a template file path. The second element will have the path/to/your/template.phtml part. The list() construct will assign the first element to the $module variable, and the second element to the $filePath module.

Next, the system checks if the $module variable is a string with contents, or empty. If it's not empty, the $params array will have an element, module, defined as the first part of the file path in the template.

Finally, the system passes some of the $params and the $filePath off to another class and method that actually takes that information and finds the template file.

Let's recap what we've seen up to this point: Magento 2 tries to extract a module name from the first part of the template path. If it finds one, it will pass that as the module that contains the template.

But, what happens if there's no first part in that template path or if the system never finds the :: substring? The answer is in the method that calls the first function we looked at. The first function was Magento\Framework\View\Filesystem::getTemplateFileName(), and the method that calls it is Magento\Framework\View\Element\Template::getTemplateFile(). Here it is:

    //Lines 199-207

    public function getTemplateFile($template = null)
    {
        $params = ['module' => $this->getModuleName()];
        $area = $this->getArea();
        if ($area) {
            $params['area'] = $area;
        }
        return $this->resolver->getTemplateFileName($template ?: $this->getTemplate(), $params);
    }

This is where the $params array is initialized. Do you see, in the first line, how the default module element is provided by another function (getModuleName)? In Magento 2, Magento\Framework\View\Element\Template is the generic template block, very similar to Mage_Core_Block_Template in Magento 1. All that function (getModuleName) does is return the name of the module that the current block is from.

If the template path doesn't provide directions on what module to find the template in, the core code assumes that the template is associated with the same module as the block. This is why it's so important to include ModuleIdentifier:: before the template path when changing a block's template. It is the same as prepending your module name to the template path.

We started this deep dive with two questions: Why would the team behind Magento 2 choose an approach like this? And, what was going on internally to the Magento 2 codebase when the system was trying to locate a template? While the core team is the only people who can definitively answer question number 1, it seems to allow for a great degree of modularity, especially in comparison to the old app/design template structure. And, I think we've seen what goes on internally in finding a template!

I'll close with a best practices tip: I think using the ModuleIdentifier:: syntax is a good thing to be in the habit of, even when it's not required. I can see it keeping things more clean and clear. Happy coding!

Tyler Schade

Former Developer at SwiftOtter