Build a Custom Magento 2 Module
Step by Step
A clean Magento 2 module is not just registration.php and module.xml. Production ready code also needs configuration, ACL, an admin menu item, layout XML, a ViewModel and clear dependency injection.
Table of Contents
1. Goal of the module
To create a Magento 2 module means more than dropping a few files under app/code. A module is a self-contained functional unit with a name, registration, configuration, permissions and clear entry points. In this tutorial we build a small module called Mironsoft_HelloModule that renders a frontend page and can be enabled or disabled from the admin area.
The focus is on a structure that also holds up in real projects. The module gets registration.php, etc/module.xml, etc/config.xml, etc/adminhtml/system.xml, etc/acl.xml, its own frontend route, a controller, layout XML, a Hyva compatible template and a ViewModel. This is exactly how you should create a Magento 2 module if it is meant to be a maintainable extension rather than just a demo.
We deliberately avoid Luma specific UI components, Knockout.js and jQuery. For frontend logic, Alpine.js would be the right choice in Hyva. In this example, server side rendering with a ViewModel is enough. It is fast, testable and fits well with Magento 2.4.8 on PHP 8.4.
2. Setting up the module structure
By default, Magento module structures live under app/code/Vendor/Module. For this tutorial the full path is app/code/Mironsoft/HelloModule. The vendor is Mironsoft, the module is called HelloModule, and the full module name is Mironsoft_HelloModule. This naming matters because it reappears in XML files, ACL resources, configuration and CLI output.
If you want to create a Magento 2 module, you should separate the directories cleanly from the start. Global configuration lives in etc. Admin specific configuration lives in etc/adminhtml. Frontend routes live in etc/frontend. Templates belong in view/frontend/templates, layout XML in view/frontend/layout. This convention makes later extensions easier to follow.
app/code/Mironsoft/HelloModule/
├── Controller/
│ └── Index/
│ └── Index.php
├── etc/
│ ├── acl.xml
│ ├── config.xml
│ ├── module.xml
│ ├── adminhtml/
│ │ └── system.xml
│ └── frontend/
│ └── routes.xml
├── registration.php
├── ViewModel/
│ └── Hello.php
└── view/
└── frontend/
├── layout/
│ └── hellomodule_index_index.xml
└── templates/
└── hello.phtml
The first file is app/code/Mironsoft/HelloModule/registration.php. It registers the module with the Magento Component Registrar. Without this file, Magento will not recognize the extension.
<?php
declare(strict_types=1);
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Mironsoft_HelloModule',
__DIR__
);
Next comes app/code/Mironsoft/HelloModule/etc/module.xml. This file describes the module to Magento. For simple modules, the name is enough. If your module depends on other modules, you would add a sequence here.
<?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="Mironsoft_HelloModule"/>
</config>
After these two files, Magento can basically recognize the module. In a Mark Shust environment, you use the wrapper for this, not php bin/magento directly. The typical flow is bin/magento module:status, bin/magento setup:upgrade and then clearing the cache. In real projects, code quality also belongs here: PHPStan, PHPCS and targeted tests.
3. Configuration, system section and ACL
A production Magento 2 module should ship its own settings. This lets the shop owner enable features, change texts or control behavior without touching code. For our example there is an enabled setting and a headline text. We define the default values in app/code/Mironsoft/HelloModule/etc/config.xml.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
<default>
<hellomodule>
<general>
<enabled>1</enabled>
<headline>Willkommen bei Mironsoft</headline>
</general>
</hellomodule>
</default>
</config>
For the setting to be visible in the admin area, the module needs app/code/Mironsoft/HelloModule/etc/adminhtml/system.xml. This creates its own configuration section with its own menu entry. This is exactly the step many developers forget when they create a Magento 2 module, resulting in a module without a proper interface.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
<system>
<tab id="mironsoft" translate="label" sortOrder="900">
<label>Mironsoft</label>
</tab>
<section id="hellomodule" translate="label" sortOrder="10"
showInDefault="1" showInWebsite="1" showInStore="1">
<label>Hello Module</label>
<tab>mironsoft</tab>
<resource>Mironsoft_HelloModule::config</resource>
<group id="general" translate="label" sortOrder="10"
showInDefault="1" showInWebsite="1" showInStore="1">
<label>General Settings</label>
<field id="enabled" translate="label" type="select" sortOrder="10"
showInDefault="1" showInWebsite="1" showInStore="1">
<label>Enabled</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
</field>
<field id="headline" translate="label" type="text" sortOrder="20"
showInDefault="1" showInWebsite="1" showInStore="1">
<label>Headline</label>
<comment>Displayed on the frontend example page.</comment>
</field>
</group>
</section>
</system>
</config>
The resource entry references an ACL resource. For this, the module needs app/code/Mironsoft/HelloModule/etc/acl.xml. Without ACL, Magento cannot properly check admin permissions. Anyone who wants to create a Magento 2 module should think about ACL from the start, even for small modules.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
<acl>
<resources>
<resource id="Magento_Backend::admin">
<resource id="Magento_Backend::stores">
<resource id="Magento_Backend::stores_settings">
<resource id="Magento_Config::config">
<resource id="Mironsoft_HelloModule::config"
title="Mironsoft Hello Module Configuration"
sortOrder="10"/>
</resource>
</resource>
</resource>
</resource>
</resources>
</acl>
</config>
4. Route, controller, layout and Hyva template
Next, the module gets a frontend page. We define the route in app/code/Mironsoft/HelloModule/etc/frontend/routes.xml. The frontName determines the URL. In this example it is /hellomodule. The controller then lives under Controller/Index/Index.php.
<?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="hellomodule" frontName="hellomodule">
<module name="Mironsoft_HelloModule"/>
</route>
</router>
</config>
The controller should stay thin. It only creates the page result and contains no business logic. This fits modern Magento architecture: controllers orchestrate HTTP, ViewModels provide data for templates, and services contain the business logic.
<?php
declare(strict_types=1);
namespace Mironsoft\HelloModule\Controller\Index;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\View\Result\PageFactory;
/**
* Renders the hello module frontend page.
*/
final class Index implements HttpGetActionInterface
{
public function __construct(
private readonly PageFactory $pageFactory
) {}
/**
* Creates the page result for the hello module route.
*/
public function execute(): ResultInterface
{
return $this->pageFactory->create();
}
}
The layout XML connects the route with the template. The handle results from route, controller and action: hellomodule_index_index.xml. The file lives under app/code/Mironsoft/HelloModule/view/frontend/layout/hellomodule_index_index.xml.
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="content">
<block class="Magento\Framework\View\Element\Template"
name="mironsoft.hellomodule.page"
template="Mironsoft_HelloModule::hello.phtml">
<arguments>
<argument name="view_model" xsi:type="object">Mironsoft\HelloModule\ViewModel\Hello</argument>
</arguments>
</block>
</referenceContainer>
</body>
</page>
5. ViewModel instead of block logic
In Hyva projects, a ViewModel is usually the better choice over a dedicated block class. The ViewModel implements ArgumentInterface and provides typed methods for the template. This keeps rendering lean and the data logic testable. If you want to create a Magento 2 module that fits Hyva, this separation is especially important.
Our ViewModel reads the configuration. For that, it uses ScopeConfigInterface. We define the paths as constants. This avoids magic strings in the code and makes the values easier to reuse later.
<?php
declare(strict_types=1);
namespace Mironsoft\HelloModule\ViewModel;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Magento\Store\Model\ScopeInterface;
/**
* Provides configuration values for the hello module template.
*/
final class Hello implements ArgumentInterface
{
/**
* Defines the enabled configuration path.
*/
private const string XML_PATH_ENABLED = 'hellomodule/general/enabled';
/**
* Defines the headline configuration path.
*/
private const string XML_PATH_HEADLINE = 'hellomodule/general/headline';
public function __construct(
private readonly ScopeConfigInterface $scopeConfig
) {}
/**
* Checks whether the module output should be visible.
*/
public function isEnabled(): bool
{
return $this->scopeConfig->isSetFlag(
self::XML_PATH_ENABLED,
ScopeInterface::SCOPE_STORE
);
}
/**
* Returns the configured frontend headline.
*/
public function getHeadline(): string
{
return (string) $this->scopeConfig->getValue(
self::XML_PATH_HEADLINE,
ScopeInterface::SCOPE_STORE
);
}
}
Finally comes the template app/code/Mironsoft/HelloModule/view/frontend/templates/hello.phtml. It uses no external scripts, no jQuery and no Luma dependencies. Values are escaped. If an inline script were needed, $hyvaCsp->registerInlineScript() would have to follow it directly in a Hyva CSP theme. In this example we do not need any JavaScript.
<?php
declare(strict_types=1);
use Magento\Framework\Escaper;
use Magento\Framework\View\Element\Template;
use Mironsoft\HelloModule\ViewModel\Hello;
/**
* @var Template $block
* @var Escaper $escaper
* @var Hello $viewModel
*/
$viewModel = $block->getData('view_model');
?>
<?php if ($viewModel && $viewModel->isEnabled()): ?>
<section class="mx-auto max-w-3xl px-4 py-12">
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<p class="text-sm font-semibold uppercase tracking-wide text-blue-700">
Mironsoft Hello Module
</p>
<h2 class="mt-2 text-2xl font-bold text-slate-900">
<?= $escaper->escapeHtml($viewModel->getHeadline()) ?>
</h2>
<p class="mt-3 text-sm leading-6 text-slate-700">
Dieses Template wird über Layout XML eingebunden und erhält seine Daten aus einem ViewModel.
</p>
</div>
</section>
<?php endif; ?>
After creating the files, you activate the module through the Magento CLI in the Docker wrapper. In this project that means: bin/magento setup:upgrade, then clearing the cache. For frontend changes in the theme, the deploy sequence with Tailwind build, static files and cache would be relevant. For a plain module template, a simple cache clean is often enough in development, depending on mode and environment.
6. Comparison: minimal module vs. production module
Many tutorials only show registration.php and module.xml. That is enough to make a module visible, but not enough for production work. A real module needs clear configuration, permissions, a clean view layer and traceable extension points. Anyone who wants to create a Magento 2 module should therefore choose a complete structure from the start.
| Area | Minimal module | Production module |
|---|---|---|
| Registration | registration.php, module.xml | Plus clear dependencies and a versioning strategy |
| Configuration | Often no settings at all | config.xml, system.xml, ACL and its own admin section |
| Frontend | Block often contains logic | Layout XML, template and ViewModel cleanly separated |
| Maintainability | Built quickly, hard to extend | Clear paths, testable classes and stable contracts |
The difference shows up at the latest by the second feature. A minimal module quickly becomes messy because configuration, output and logic run into each other. A production module has more files, but fewer hidden dependencies. That is exactly what makes Magento development faster in the long run.
Mironsoft
Magento 2 modules, Hyva themes and technical architecture
Want a Magento 2 module built the clean way?
We build Magento 2 extensions with configuration, ACL, Service Contracts, ViewModels and Hyva compatible output. Clean enough for maintenance, tests and future extensions.
Module structure
registration.php, module.xml, config.xml, system.xml and ACL
Frontend
Layout XML, ViewModels and Hyva templates without Luma overhead
Quality
PHP 8.4, dependency injection, tests and clear extension points
8. Summary
Creating your own Magento 2 module means building a clear, extensible structure. The base consists of registration.php and module.xml. For a professional module, config.xml, system.xml, acl.xml, routes, controller, layout XML, template and ViewModel are added on top.
The most important architectural point is separation of responsibilities. Controllers stay thin, ViewModels provide data, templates render HTML, and configuration lives in the admin area. This keeps the module maintainable and fits well with Magento 2.4.8, PHP 8.4 and Hyva.
Creating a Magento 2 Module: The Essentials at a Glance
Path
app/code/Mironsoft/HelloModule with a clear vendor and module structure.
Required base
registration.php and etc/module.xml register the module.
Admin
config.xml, system.xml and acl.xml make settings usable and protectable by permissions.
Hyva
Provide data through ViewModels, keep templates lean and load no jQuery or Knockout.js.
9. FAQ: Creating a Custom Magento 2 Module
1 What files does a Magento 2 module need at minimum?
registration.php and etc/module.xml. For production ready code, configuration, ACL, routes, layout XML and ViewModels are added.2 Where does a custom Magento 2 module live?
app/code/Vendor/Module, for example app/code/Mironsoft/HelloModule.3 Why does a module need system.xml?
4 What is acl.xml responsible for?
5 Block or ViewModel for Hyva?
ArgumentInterface is usually better. Block classes should not be filled with business logic.6 How do you activate a new module?
bin/magento setup:upgrade followed by clearing the cache.7 What does routes.xml do?
frontName becomes part of the URL.8 What is the layout handle called?
hellomodule/index/index, the layout file is called hellomodule_index_index.xml.