Every customisation in Magento 2 lives in a module: a folder with a few configuration files that tells Magento what your code does and how it fits into the platform. In this guide we build a module from scratch, enable it, and add a small storefront page that uses a ViewModel and escapes output properly — the way we write modules for client stores today.
The code targets Magento Open Source and Adobe Commerce 2.4.8 and 2.4.9 with PHP 8.3 or newer. It uses constructor property promotion and strict types, which every supported Magento version now allows.
Before You Start
- A working Magento 2.4.x installation in developer mode (
bin/magento deploy:mode:set developer). - Shell access so you can run
bin/magentocommands. - A vendor name for your company and a module name. We use
MageServicesandHello, so the module isMageServices_Hello.
Custom modules go inapp/code/Vendor/Moduleduring development. For modules you share between projects, package them with Composer so they install intovendor/instead.
Step 1: Create the Module Folder
mkdir -p app/code/MageServices/Hello/etc
Step 2: Add registration.php
This file registers the module with Magento's component registrar. Without it, Magento will not find your code.
<?php
declare(strict_types=1);
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'MageServices_Hello',
__DIR__
);
Step 3: Add etc/module.xml
The module declaration gives the module its name and, optionally, a load order through <sequence>. If your module changes behaviour from another module — for example the catalog — list it here so its configuration loads first. You no longer need a setup_version attribute when you use declarative schema and patches.
<?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="MageServices_Hello">
<sequence>
<module name="Magento_Theme"/>
</sequence>
</module>
</config>
Step 4: Add composer.json
A composer.json is optional for modules in app/code, but adding it from the start makes it easy to move the module into its own package later.
{
"name": "mageservices/module-hello",
"description": "Example Magento 2 module",
"type": "magento2-module",
"license": "proprietary",
"require": {
"php": "~8.3.0||~8.4.0||~8.5.0",
"magento/framework": "*"
},
"autoload": {
"files": ["registration.php"],
"psr-4": {
"MageServices\\Hello\\": ""
}
}
}
Step 5: Enable the Module
bin/magento module:status MageServices_Hello
bin/magento module:enable MageServices_Hello
bin/magento setup:upgrade
bin/magento cache:flush
module:enable adds the module to app/etc/config.php. Commit that file to Git so every environment enables the same modules. setup:upgrade applies any database schema and data patches the module contains.
Step 6: Add a Storefront Page
A module that does nothing is hard to test, so let us add a page at /hello. We need a route, a controller, a layout file, a ViewModel and a template.
Route
<?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="hello" frontName="hello">
<module name="MageServices_Hello"/>
</route>
</router>
</config>
Controller
Controllers implement an HTTP method interface such as HttpGetActionInterface. Extending the old Magento\Framework\App\Action\Action class is deprecated.
<?php
declare(strict_types=1);
namespace MageServices\Hello\Controller\Index;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\Page;
use Magento\Framework\View\Result\PageFactory;
class Index implements HttpGetActionInterface
{
public function __construct(
private readonly PageFactory $pageFactory
) {
}
public function execute(): Page
{
$page = $this->pageFactory->create();
$page->getConfig()->getTitle()->set(__('Hello from MageServices'));
return $page;
}
}
ViewModel
Put logic in a ViewModel instead of a custom Block class. ViewModels implement ArgumentInterface, receive their dependencies through the constructor and are passed to templates from layout XML.
<?php
declare(strict_types=1);
namespace MageServices\Hello\ViewModel;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Magento\Store\Model\StoreManagerInterface;
class Greeting implements ArgumentInterface
{
public function __construct(
private readonly StoreManagerInterface $storeManager
) {
}
public function getStoreName(): string
{
return (string) $this->storeManager->getStore()->getName();
}
}
Layout
The layout handle is {route id}_{controller folder}_{action}, so our handle is hello_index_index. A block without a class attribute uses the generic Magento\Framework\View\Element\Template.
<?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 name="mageservices.hello" template="MageServices_Hello::hello.phtml">
<arguments>
<argument name="greeting_view_model" xsi:type="object">MageServices\Hello\ViewModel\Greeting</argument>
</arguments>
</block>
</referenceContainer>
</body>
</page>
Template
Templates receive an $escaper variable. Escape every dynamic value: escapeHtml() for text, escapeHtmlAttr() for attributes and escapeUrl() for links. The old $block->escapeHtml() helpers are deprecated.
<?php
declare(strict_types=1);
use Magento\Framework\Escaper;
use Magento\Framework\View\Element\Template;
use MageServices\Hello\ViewModel\Greeting;
/** @var Template $block */
/** @var Escaper $escaper */
/** @var Greeting $greeting */
$greeting = $block->getData('greeting_view_model');
?>
<div class="mageservices-hello">
<h2><?= $escaper->escapeHtml(__('Welcome to %1', $greeting->getStoreName())) ?></h2>
<p><?= $escaper->escapeHtml(__('This page is rendered by the MageServices_Hello module.')) ?></p>
</div>
Flush the cache and open https://your-store.test/hello. You should see the heading with your store name.
bin/magento cache:flush
Final Module Structure
app/code/MageServices/Hello/
├── Controller/Index/Index.php
├── ViewModel/Greeting.php
├── composer.json
├── etc/
│ ├── frontend/routes.xml
│ └── module.xml
├── registration.php
└── view/frontend/
├── layout/hello_index_index.xml
└── templates/hello.phtml
Common Problems
| Symptom | Likely cause |
|---|---|
Module not listed in module:status | Typo in registration.php or the folder name does not match Vendor/Module. |
404 on /hello | Route file is not in etc/frontend/, or the cache was not flushed. |
| Blank page | Layout handle name does not match the route, controller folder and action. |
| Class not found in production | Run setup:di:compile after adding classes when not in developer mode. |
Next Steps
From here you can add database tables with declarative schema, change core behaviour with plugins, or react to events with observers. If you run Hyvä, the same ViewModel pattern is how Hyvä templates get their data.
Frequently Asked Questions
Where should I put a custom Magento 2 module?
In app/code/Vendor/Module for project-specific code. Reusable modules are better packaged with Composer and installed into vendor/.
Do I still need setup_version in module.xml?
No. Modules that use declarative schema and data patches do not need setup_version.
Why use a ViewModel instead of a Block?
ViewModels keep logic out of templates, use constructor injection and do not depend on the block hierarchy, which makes them easier to test and to reuse in Hyvä themes.
Need help with this on your store?
Our Magento engineers can implement it for you, review your code or take on the whole project.
Written by the Magento Services engineering team — Magento 2, Adobe Commerce and Hyvä specialists since 2014. We write about problems we solve on real client stores.