Create a Custom Magento 2 Module | Step by Step Tutorial
AI generated
Magento 2 · Module Development

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.

14 min read PHP 8.4 Magento 2.4.8

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?
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?
Under app/code/Vendor/Module, for example app/code/Mironsoft/HelloModule.
3 Why does a module need system.xml?
So that settings are visible and maintainable in the admin area. That way the shop owner does not need to change code for simple options.
4 What is acl.xml responsible for?
ACL protects admin functions and configuration through permissions. The system.xml section references an ACL resource.
5 Block or ViewModel for Hyva?
For data in the template, a ViewModel with ArgumentInterface is usually better. Block classes should not be filled with business logic.
6 How do you activate a new module?
In the Mark Shust setup through wrapper commands, for example bin/magento setup:upgrade followed by clearing the cache.
7 What does routes.xml do?
It defines the frontend or admin route. The frontName becomes part of the URL.
8 What is the layout handle called?
For hellomodule/index/index, the layout file is called hellomodule_index_index.xml.
9 Does every module need db_schema.xml?
No. Only if custom database tables or columns are needed. In that case, use declarative schema, not old InstallScripts.
10 What standards apply to new modules?
Constructor property promotion, English PHPDocs, dependency injection, Service Contracts where sensible, plugins instead of preferences, and ViewModels for templates.