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/magento commands.
  • A vendor name for your company and a module name. We use MageServices and Hello, so the module is MageServices_Hello.
Custom modules go in app/code/Vendor/Module during development. For modules you share between projects, package them with Composer so they install into vendor/ 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.

app/code/MageServices/Hello/registration.php
<?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.

app/code/MageServices/Hello/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="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.

app/code/MageServices/Hello/composer.json
{
    "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

app/code/MageServices/Hello/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="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.

app/code/MageServices/Hello/Controller/Index/Index.php
<?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.

app/code/MageServices/Hello/ViewModel/Greeting.php
<?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.

app/code/MageServices/Hello/view/frontend/layout/hello_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 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.

app/code/MageServices/Hello/view/frontend/templates/hello.phtml
<?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

SymptomLikely cause
Module not listed in module:statusTypo in registration.php or the folder name does not match Vendor/Module.
404 on /helloRoute file is not in etc/frontend/, or the cache was not flushed.
Blank pageLayout handle name does not match the route, controller folder and action.
Class not found in productionRun 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.

Explore Magento Extension Development
MS
About the author

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.