In Magento 2, every custom page is the result of three components working in concert: a controller that handles the request, a layout XML file that defines the page structure, and a template file that renders the HTML. If you have built anything on Magento 1.x, the new system will feel more structured and predictable — because it is. This guide walks you through creating a fully functional custom frontend page from scratch, covering every required file, the correct directory structure, and the routing conventions that tie everything together.
By the end of this tutorial you will have a working custom module with a live frontend URL, a routed controller using Dependency Injection, a declarative layout XML file, a block class, and a PHTML template. All code examples are compatible with Magento 2.4.x and Adobe Commerce 2.4.8, the current stable release.
What Is a Controller in Magento 2?
A controller is a PHP class responsible for handling a specific URL request. When a visitor hits a URL like yourdomain.com/mymodule/index/index, Magento’s router matches the first URL segment (mymodule) to a registered module, the second segment (index) to a controller folder, and the third segment (index) to an action class file. The controller’s execute() method runs, interacts with any required services, and returns a result object — typically a ResultPage that triggers layout and template rendering.
Unlike Magento 1.x, where a single controller file handled multiple actions via methods like indexAction(), Magento 2 gives each action its own class file. This makes the codebase easier to navigate, test, and extend.
What Is a Layout XML File in Magento 2?
A layout XML file is the presentation instruction set for a specific page. It tells Magento which containers to use, which blocks to load inside them, and which PHTML templates to render. The file name is not arbitrary — it must match the layout handle, which is constructed from the route ID, controller name, and action name: [routeid]_[controllername]_[actionname].xml.
The layout system separates structure from logic. You can move a block from one container to another by changing a single XML line, without touching PHP or template code. This declarative approach is a significant improvement over Magento 1.x’s handle-based layout system.
Prerequisites
Before starting, you need a Magento 2 installation with write access to the app/code directory. This guide uses a module named MyCompany_MyModule. All files will be created under app/code/MyCompany/MyModule. Replace MyCompany and MyModule with your own vendor and module names throughout.
Step 1: Create the Module Declaration (registration.php)
Every Magento 2 module begins with a registration.php file in the module root. This file uses the ComponentRegistrar class to make Magento aware of the module’s existence. Without it, none of your subsequent files will have any effect.
File path: app/code/MyCompany/MyModule/registration.php
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'MyCompany_MyModule',
__DIR__
);
After creating this file, run bin/magento setup:upgrade from your Magento root. This command registers the new module and makes it active.
Step 2: Declare the Module (etc/module.xml)
The etc/module.xml file declares the module’s name and version. In Magento 2, this file is focused solely on module identity and load-order dependencies — it no longer serves as a monolithic configuration hub like Magento 1.x’s config.xml.
File path: app/code/MyCompany/MyModule/etc/module.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="MyCompany_MyModule">
<sequence>
<module name="Magento_Store"/>
</sequence>
</module>
</config>
The name attribute must exactly match what you used in registration.php. The <sequence> block declares load-order dependencies, ensuring that Magento_Store is initialised before your module.
Step 3: Register the Frontend Route (etc/frontend/routes.xml)
Routing in Magento 2 is declared separately from the controller, in a dedicated XML file. For frontend pages, this file lives at etc/frontend/routes.xml. For admin pages, it would be etc/adminhtml/routes.xml.
File path: app/code/MyCompany/MyModule/etc/frontend/routes.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
<router id="standard">
<route id="mymodule" frontName="mymodule">
<module name="MyCompany_MyModule"/>
</route>
</router>
</config>
The frontName attribute defines the first segment of your URL. With this configuration, any request to yourdomain.com/mymodule/ will be routed to your module. The id on the <route> tag is also used as the first segment of your layout file name — so keep it consistent with your frontName.
Step 4: Create the Controller (Controller/Index/Index.php)
With routing in place, create the controller class. The file path mirrors the URL segments that follow the front name. For yourdomain.com/mymodule/index/index, the file path is Controller/Index/Index.php.
File path: app/code/MyCompany/MyModule/Controller/Index/Index.php
<?php
namespace MyCompany\MyModule\Controller\Index;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\PageFactory;
class Index implements HttpGetActionInterface
{
/**
* @var PageFactory
*/
private PageFactory $pageFactory;
public function __construct(PageFactory $pageFactory)
{
$this->pageFactory = $pageFactory;
}
public function execute()
{
return $this->pageFactory->create();
}
}
Note the use of HttpGetActionInterface rather than extending the older \Magento\Framework\App\Action\Action class. This is the recommended approach in Magento 2.4.x — it explicitly declares that this controller handles GET requests and removes the dependency on the abstract Action class. The PageFactory is injected via the constructor, following Magento 2’s Dependency Injection pattern.
The execute() method simply creates and returns a ResultPage object. Magento uses the request’s route, controller, and action names to automatically find and load the matching layout XML file.
Step 5: Create the Layout XML File
The layout XML file connects the controller output to the page structure. Its file name must follow the naming convention: [routeid]_[controllername]_[actionname].xml. For our module, that is mymodule_index_index.xml.
File path: app/code/MyCompany/MyModule/view/frontend/layout/mymodule_index_index.xml
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
layout="1column"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="content">
<block class="MyCompany\MyModule\Block\Myblock"
name="mymodule_content_block"
template="MyCompany_MyModule::myblock.phtml"/>
</referenceContainer>
</body>
</page>
The layout="1column" attribute sets the page’s overall layout. The <referenceContainer name="content"> element targets the main content area defined in Magento’s core layout files. Inside it, the <block> element references our block class and template file.
The template path format is VendorName_ModuleName::path/to/file.phtml, where the path after :: is relative to view/frontend/templates/.
Step 6: Create the Block Class (Block/Myblock.php)
The block class bridges the data layer and the template. It extends \Magento\Framework\View\Element\Template and contains any methods the template needs to retrieve or prepare data.
File path: app/code/MyCompany/MyModule/Block/Myblock.php
<?php
namespace MyCompany\MyModule\Block;
use Magento\Framework\View\Element\Template;
class Myblock extends Template
{
/**
* Returns a greeting message for the template.
*
* @return string
*/
public function getGreeting(): string
{
return 'Hello from MyCompany_MyModule!';
}
}
The template file can call any public method on this class using the $block variable. Keep the block class lean — it should prepare and expose data, not perform heavy business logic. Move complex operations to a service class or model and inject that into the block’s constructor.
Step 7: Create the Template File (view/frontend/templates/myblock.phtml)
The template file renders the final HTML. In Magento 2, the block is available inside the template via the $block variable, replacing the Magento 1.x convention of using $this.
File path: app/code/MyCompany/MyModule/view/frontend/templates/myblock.phtml
<?php
/** @var \MyCompany\MyModule\Block\Myblock $block */
$greeting = $block->escapeHtml($block->getGreeting());
?>
<div class="mymodule-content">
<h2><?= $greeting ?></h2>
<p>Your custom Magento 2 page is working correctly.</p>
</div>
Always use $block->escapeHtml() when outputting any string that originates from user input or a database value. This prevents XSS vulnerabilities and is a mandatory practice for Magento security compliance.
Step 8: Run Setup Commands and Clear Cache
After creating all the files above, run the following commands from your Magento root directory in sequence:
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flush
setup:upgrade registers the new module. setup:di:compile regenerates the Dependency Injection configuration — this step is critical whenever you add new constructor arguments or create new classes. cache:flush clears all cached layout and configuration data.
Navigate to yourdomain.com/mymodule/index/index to confirm the page loads correctly.
How the Magento 2 Request Flow Works
Understanding the full request cycle makes troubleshooting far faster. When a visitor hits yourdomain.com/mymodule/index/index, the following sequence occurs:
- Router matching: Magento scans all active modules for a matching
frontName. Youretc/frontend/routes.xmlmapsmymoduletoMyCompany_MyModule. - Controller resolution: The router resolves
index/indexto the class atMyCompany\MyModule\Controller\Index\Index. - Controller execution: The
execute()method runs and returns aResultPageobject. - Layout loading: The
ResultPageresolves the layout handle (mymodule_index_index) and loads the matching XML file fromview/frontend/layout/. - Block instantiation: Magento instantiates
MyCompany\MyModule\Block\Myblockand associates it with the template file. - HTML rendering: The template renders the HTML, which is injected into the content container and wrapped in the store’s standard header, footer, and page chrome.
Common Troubleshooting Issues
404 Not Found
A 404 error almost always indicates a routing problem. Check the following:
- Did you run
bin/magento setup:upgradeafter creatingregistration.phpandmodule.xml? - Is your
frontNameinroutes.xmlunique across all installed modules? - Does the controller file path (
Controller/Index/Index.php) and class name (MyCompany\MyModule\Controller\Index\Index) match exactly?
If you encounter a blank grey page in the Magento admin after adding a new module, the most common cause is a PHP syntax error in one of your new files — enable developer mode to see the full stack trace.
Blank Page or Uncaught Exception
Switch to developer mode by editing app/etc/env.php and setting MAGE_MODE to developer. This disables error suppression and outputs detailed exception messages directly to the browser. After diagnosing, recompile with bin/magento setup:di:compile and flush the cache.
Block or Template Not Found
Verify that the template path in the layout XML matches the actual file location. The path MyCompany_MyModule::myblock.phtml translates to app/code/MyCompany/MyModule/view/frontend/templates/myblock.phtml. A mismatch in capitalisation or directory nesting is the most common cause.
HTTP Errors After Deployment
Server-level HTTP errors such as timeouts are separate from Magento’s routing. If you are seeing a 408 Request Timeout error during heavy operations like setup:di:compile, increase your server’s PHP execution timeout. On Apache, review your AllowOverride settings in Apache2 to ensure .htaccess directives are being respected. Similarly, if you hit PHP configuration limits, fixing max_execution_time and input_vars in your php.ini will resolve most compile-time failures.
Caching Issues
After any change to layout XML, routing configuration, or DI configuration, always run bin/magento cache:flush. For layout changes alone, bin/magento cache:clean layout block_html full_page is sufficient and faster. For any changes that affect constructor injection or class generation, you must re-run bin/magento setup:di:compile.
Frequently Asked Questions
What is the difference between a container and a block in Magento 2 layout XML?
Containers are structural wrappers — they define regions of the page like the main content area, left sidebar, or footer links, but they do not render HTML directly. Blocks are the content units that actually output HTML, and they always reference a PHP class and a PHTML template. You add blocks inside containers to place content in specific regions of the page.
Do I need a block class for every template in Magento 2?
Not always. If your template does not need any custom PHP methods, you can use Magento’s generic Magento\Framework\View\Element\Template class directly in the layout XML without creating a custom block file. This is acceptable for simple static content templates, but for anything that fetches data you should create a dedicated block class to keep logic out of the template.
How does Magento 2 know which layout file to load for a controller?
Magento automatically derives the layout handle from the request: it combines the route ID, the controller folder name, and the action class name using underscores. For a route ID of mymodule, a controller folder named Index, and an action class named Index, the resulting layout handle is mymodule_index_index — and Magento loads mymodule_index_index.xml from your module’s view/frontend/layout/ directory automatically.