← Back to blog

How to Create a Custom Magento 2 Module (2.4.8 Guide)

Rodrigo Biazzi

Most Magento 2 tutorials for custom modules were written for 2.1 or 2.2 and still show install scripts, setup_version attributes and other things that no longer belong in a new module. This guide builds a small module for Magento Open Source or Adobe Commerce 2.4.8 the way I would write it today: declarative schema, data patches, a frontend route, a view model and a plugin, all running on PHP 8.3 or 8.4.

The example module is called Acme_StoreNotice. It stores short notices in a custom table and shows them on a frontend page. Replace the vendor and module names with your own.

Where the module lives

For project specific code, app/code/Vendor/Module is fine. If the module will be shared between projects or installed through Composer, give it a composer.json and keep it in its own repository. Magento loads both the same way, the only difference is how the code reaches the server.

app/code/Acme/StoreNotice/
β”œβ”€β”€ registration.php
β”œβ”€β”€ composer.json
β”œβ”€β”€ etc/
β”‚   β”œβ”€β”€ module.xml
β”‚   β”œβ”€β”€ db_schema.xml
β”‚   β”œβ”€β”€ db_schema_whitelist.json
β”‚   β”œβ”€β”€ di.xml
β”‚   └── frontend/
β”‚       └── routes.xml
β”œβ”€β”€ Controller/Index/Index.php
β”œβ”€β”€ Model/
β”œβ”€β”€ Setup/Patch/Data/AddDefaultNotice.php
β”œβ”€β”€ ViewModel/NoticeList.php
└── view/frontend/
    β”œβ”€β”€ layout/storenotice_index_index.xml
    └── templates/list.phtml

The two required files

A module needs exactly two files to exist: registration.php and etc/module.xml. Everything else is optional.

registration.php

<?php
declare(strict_types=1);

use Magento\Framework\Component\ComponentRegistrar;

ComponentRegistrar::register(
    ComponentRegistrar::MODULE,
    'Acme_StoreNotice',
    __DIR__
);

etc/module.xml

Leave out setup_version. Since declarative schema arrived in 2.3, the version attribute is not needed, and keeping it only invites old style upgrade scripts. Use sequence for modules that must load before yours, for example when you extend their layout or override their configuration.

<?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="Acme_StoreNotice">
        <sequence>
            <module name="Magento_Store"/>
        </sequence>
    </module>
</config>

Now enable it and run the setup upgrade:

bin/magento module:enable Acme_StoreNotice
bin/magento setup:upgrade
bin/magento module:status Acme_StoreNotice

After setup:upgrade the module appears in app/etc/config.php. Commit that file. Deployments rely on it to know which modules are enabled.

A composer.json for the module

Even in app/code, a composer.json documents the PHP and framework requirements. It becomes mandatory once you move the module into its own package.

{
    "name": "acme/module-store-notice",
    "description": "Store notices shown on a frontend page",
    "type": "magento2-module",
    "license": "proprietary",
    "require": {
        "php": "~8.3.0||~8.4.0",
        "magento/framework": "*",
        "magento/module-store": "*"
    },
    "autoload": {
        "files": ["registration.php"],
        "psr-4": {
            "Acme\\StoreNotice\\": ""
        }
    }
}

Database tables with declarative schema

Tables are described in etc/db_schema.xml. Magento compares this file with the live database on every setup:upgrade and creates or alters what differs. You never write CREATE TABLE yourself.

<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="acme_store_notice" resource="default" engine="innodb" comment="Store notices">
        <column xsi:type="int" name="notice_id" unsigned="true" nullable="false" identity="true" comment="Notice ID"/>
        <column xsi:type="smallint" name="store_id" unsigned="true" nullable="false" default="0" comment="Store ID"/>
        <column xsi:type="varchar" name="message" length="255" nullable="false" comment="Message"/>
        <column xsi:type="boolean" name="is_active" nullable="false" default="1" comment="Is Active"/>
        <column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" on_update="false" comment="Created At"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="notice_id"/>
        </constraint>
        <constraint xsi:type="foreign" referenceId="ACME_STORE_NOTICE_STORE_ID_STORE_STORE_ID"
                    table="acme_store_notice" column="store_id"
                    referenceTable="store" referenceColumn="store_id" onDelete="CASCADE"/>
        <index referenceId="ACME_STORE_NOTICE_STORE_ID_IS_ACTIVE" indexType="btree">
            <column name="store_id"/>
            <column name="is_active"/>
        </index>
    </table>
</schema>

Then generate the whitelist. Magento uses it to know which columns and constraints your module owns, so it can safely drop them later if you remove them from the schema.

bin/magento setup:db-declaration:generate-whitelist --module-name=Acme_StoreNotice
bin/magento setup:upgrade --dry-run=1
bin/magento setup:upgrade

The dry run writes the SQL it would execute to var/log/dry-run-installation.log. I read that file before every schema change that touches a large table, because an altered index on a big table can lock it for a long time.

Seed data with a data patch

Data patches replace the old InstallData and UpgradeData scripts. Each patch runs once and is recorded in the patch_list table.

<?php
declare(strict_types=1);

namespace Acme\StoreNotice\Setup\Patch\Data;

use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;

class AddDefaultNotice implements DataPatchInterface
{
    public function __construct(
        private readonly ModuleDataSetupInterface $moduleDataSetup
    ) {
    }

    public function apply(): self
    {
        $connection = $this->moduleDataSetup->getConnection();
        $connection->insert(
            $this->moduleDataSetup->getTable('acme_store_notice'),
            ['store_id' => 0, 'message' => 'Free shipping on orders over 100', 'is_active' => 1]
        );

        return $this;
    }

    public static function getDependencies(): array
    {
        return [];
    }

    public function getAliases(): array
    {
        return [];
    }
}

Keep patches idempotent where you can. If someone deletes the row in patch_list to rerun a patch, it should not create duplicates. For anything that depends on another patch, list that class in getDependencies().

Models, resource models and collections

The classic trio still works in 2.4.8 and it is what most core modules use. A model extends Magento\Framework\Model\AbstractModel, a resource model extends Magento\Framework\Model\ResourceModel\Db\AbstractDb and a collection extends Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection.

<?php
declare(strict_types=1);

namespace Acme\StoreNotice\Model\ResourceModel;

use Magento\Framework\Model\ResourceModel\Db\AbstractDb;

class Notice extends AbstractDb
{
    protected function _construct(): void
    {
        $this->_init('acme_store_notice', 'notice_id');
    }
}
<?php
declare(strict_types=1);

namespace Acme\StoreNotice\Model;

use Magento\Framework\Model\AbstractModel;

class Notice extends AbstractModel
{
    protected function _construct(): void
    {
        $this->_init(ResourceModel\Notice::class);
    }
}
<?php
declare(strict_types=1);

namespace Acme\StoreNotice\Model\ResourceModel\Notice;

use Acme\StoreNotice\Model\Notice;
use Acme\StoreNotice\Model\ResourceModel\Notice as NoticeResource;
use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;

class Collection extends AbstractCollection
{
    protected $_idFieldName = 'notice_id';

    protected function _construct(): void
    {
        $this->_init(Notice::class, NoticeResource::class);
    }
}

If other modules or the API will use your entity, add a service contract on top: an interface in Api/Data, a repository interface in Api, and a preference in di.xml that maps each interface to its implementation. For a small internal module the collection alone is often enough.

A frontend route and controller

Routes are declared per area. This one goes in etc/frontend/routes.xml, so the URL becomes /storenotice/index/index, which also answers on /storenotice.

<?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="storenotice" frontName="storenotice">
            <module name="Acme_StoreNotice"/>
        </route>
    </router>
</config>

Controllers no longer need to extend Magento\Framework\App\Action\Action. Implement the HTTP method interface you accept and inject what you need.

<?php
declare(strict_types=1);

namespace Acme\StoreNotice\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(__('Store notices'));

        return $page;
    }
}

Layout, template and a view model

The layout handle is built from route id, controller folder and action class: storenotice_index_index. Instead of a custom block class, pass a view model as an argument to a generic template block. View models keep logic out of templates and are easy to test.

<?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="acme.store.notice.list" template="Acme_StoreNotice::list.phtml">
                <arguments>
                    <argument name="view_model" xsi:type="object">Acme\StoreNotice\ViewModel\NoticeList</argument>
                </arguments>
            </block>
        </referenceContainer>
    </body>
</page>
<?php
declare(strict_types=1);

namespace Acme\StoreNotice\ViewModel;

use Acme\StoreNotice\Model\ResourceModel\Notice\CollectionFactory;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Magento\Store\Model\StoreManagerInterface;

class NoticeList implements ArgumentInterface
{
    public function __construct(
        private readonly CollectionFactory $collectionFactory,
        private readonly StoreManagerInterface $storeManager
    ) {
    }

    public function getMessages(): array
    {
        $collection = $this->collectionFactory->create()
            ->addFieldToFilter('is_active', 1)
            ->addFieldToFilter('store_id', ['in' => [0, (int) $this->storeManager->getStore()->getId()]])
            ->setOrder('created_at', 'DESC');

        return $collection->getColumnValues('message');
    }
}

The CollectionFactory class does not exist in your code. Magento generates it on demand in developer mode and during setup:di:compile in production.

<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\Escaper $escaper */
/** @var \Acme\StoreNotice\ViewModel\NoticeList $viewModel */
$viewModel = $block->getData('view_model');
?>
<ul class="store-notices">
    <?php foreach ($viewModel->getMessages() as $message): ?>
        <li><?= $escaper->escapeHtml($message) ?></li>
    <?php endforeach; ?>
</ul>

Always escape output. The $escaper variable is available in every template since 2.4, so you no longer need $block->escapeHtml().

Changing core behaviour with a plugin

Most real modules change something Magento already does. The usual tool is a plugin declared in etc/di.xml. This one appends a suffix to every product name on the storefront. Put it in etc/frontend/di.xml so it does not affect the admin or the API.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Catalog\Model\Product">
        <plugin name="acme_store_notice_product_name"
                type="Acme\StoreNotice\Plugin\ProductNamePlugin"
                sortOrder="10"/>
    </type>
</config>
<?php
declare(strict_types=1);

namespace Acme\StoreNotice\Plugin;

use Magento\Catalog\Model\Product;

class ProductNamePlugin
{
    public function afterGetName(Product $subject, ?string $result): ?string
    {
        return $result === null ? null : $result . ' *';
    }
}

Plugins, observers and preferences each have a place. I wrote a separate article on when to use each one.

A config value in the admin

Almost every module ends up needing a switch in Stores, Configuration. The field is declared in etc/adminhtml/system.xml and its default goes in etc/config.xml. Reading it is a single call to Magento\Framework\App\Config\ScopeConfigInterface.

<?php
declare(strict_types=1);

namespace Acme\StoreNotice\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;

class Config
{
    private const XML_PATH_ENABLED = 'acme_store_notice/general/enabled';

    public function __construct(
        private readonly ScopeConfigInterface $scopeConfig
    ) {
    }

    public function isEnabled(?int $storeId = null): bool
    {
        return $this->scopeConfig->isSetFlag(self::XML_PATH_ENABLED, ScopeInterface::SCOPE_STORE, $storeId);
    }
}

I keep all config paths in one small class like this. When a path changes, there is one place to edit, and the view model or plugin only asks a clear question such as isEnabled(). You can also set the value from the command line, which is handy in deploy scripts: bin/magento config:set acme_store_notice/general/enabled 1.

Testing before you ship

Unit tests for a module go in Test/Unit and run with the PHPUnit configuration that ships with Magento:

vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist app/code/Acme/StoreNotice/Test/Unit

View models and config classes like the ones above are easy to unit test because every dependency arrives through the constructor. Controllers, layout and database code are better covered by integration tests, which need a separate test database. At minimum, run the Magento coding standard against the module with PHP_CodeSniffer before you open a pull request. It catches missing escaping in templates, direct use of superglobals and other things reviewers flag anyway.

Deploying the module

In developer mode, setup:upgrade and a cache clean are usually enough. In production mode the full sequence is:

bin/magento maintenance:enable
bin/magento setup:upgrade --keep-generated
bin/magento setup:di:compile
bin/magento setup:static-content:deploy -f en_US
bin/magento cache:flush
bin/magento maintenance:disable

Better still, build generated/ and pub/static in CI and only run setup:upgrade --keep-generated on the server. That keeps the maintenance window short.

Common mistakes

  • Wrong case in the namespace or folder. Linux file systems are case sensitive, so a module that works on macOS can fail on the server.
  • Editing db_schema.xml without regenerating db_schema_whitelist.json, which leaves columns Magento refuses to drop.
  • Putting a frontend plugin in the global etc/di.xml, so it also runs in the admin, in cron and in REST calls.
  • Using the ObjectManager directly instead of constructor injection. It works, but it hides dependencies and breaks unit tests.
  • Forgetting to commit app/etc/config.php after enabling the module.

FAQ

Do I still need setup_version in module.xml?

No. Since declarative schema and patches were introduced in Magento 2.3, setup_version is not needed for new modules. Schema changes go in db_schema.xml and data changes go in data patches.

Should a custom module live in app/code or vendor?

Project specific code can live in app/code. Code shared between projects, or anything you want to version and install independently, is better as a Composer package that ends up in vendor.

Why does my new module not show up after setup:upgrade?

Check that registration.php uses the exact module name from module.xml, that folder and namespace casing match, and that the module is listed and enabled in app/etc/config.php. bin/magento module:status shows what Magento actually sees.

Which commands do I run after changing di.xml?

In developer mode a cache clean is usually enough, plus removing the affected classes in generated/ if a constructor changed. In production mode run bin/magento setup:di:compile and then flush the cache.

I am available for freelance Magento and Adobe Commerce work, from module development and performance audits to migrations and HyvΓ€ builds. If that sounds useful, get in touch.

Related articles