How to Create a Custom Module in Magento 2 (From Scratch)
A modern, copy-paste guide to building your first Magento 2 module — the two required files, a working route, a declarative database table, and a plugin — without a single deprecated install script.
A Magento 2 module is a self-contained folder of configuration and PHP that adds or changes store behaviour. Everything in Magento — the catalog, checkout, even the admin — is built from modules, so learning to write one is the foundation of all Magento development.
This guide builds a real module from an empty folder: registration, a working frontend route, a database table with declarative schema, and a plugin that changes core behaviour — all on Magento 2.4.4 — 2.4.9, with no deprecated install scripts.
Where modules live and how they are named
During development a module lives at app/code/<Vendor>/<Module>. The vendor is your namespace (a company or author), the module is the feature. Together they form the module name joined by an underscore. Throughout this guide the vendor is Panth and the module is HelloWorld, so:
app/code/Panth/HelloWorld/
Module name: Panth_HelloWorld
Namespace: Panth\HelloWorldPick a real vendor name and keep it consistent — it is the PHP namespace, the module identifier, the config path prefix, and the Composer package name all at once. Renaming it later touches every file.
The two files Magento actually requires
A module is registered and loadable with just these two files.
registration.php
<?php
declare(strict_types=1);
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Panth_HelloWorld',
__DIR__
);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="Panth_HelloWorld">
<sequence>
<module name="Magento_Catalog"/>
</sequence>
</module>
</config>The <sequence> block lists modules that must load before yours — use it whenever you depend on another module's config or plugins. Note that module.xml no longer carries a setup_version in modern Magento; declarative schema makes it unnecessary.
Enable the module
From the Magento root:
bin/magento module:enable Panth_HelloWorld
bin/magento setup:upgrade
bin/magento cache:flushConfirm it registered:
bin/magento module:status Panth_HelloWorldYou now have a live (if empty) module. Everything below is optional behaviour you add as needed.
Add a frontend route and controller
To serve a page at /helloworld, declare a route and write a controller action. 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="helloworld" frontName="helloworld">
<module name="Panth_HelloWorld"/>
</route>
</router>
</config>The URL maps to Controller/<Path>/<Action>.php. For /helloworld/index/index (and the bare /helloworld), create Controller/Index/Index.php:
<?php
declare(strict_types=1);
namespace Panth\HelloWorld\Controller\Index;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\Controller\Result\RawFactory;
use Magento\Framework\Controller\ResultInterface;
class Index implements HttpGetActionInterface
{
public function __construct(
private readonly RawFactory $rawFactory
) {
}
public function execute(): ResultInterface
{
$result = $this->rawFactory->create();
$result->setContents('Hello from Panth_HelloWorld');
return $result;
}
}Implementing HttpGetActionInterface (rather than the deprecated Action base class) is the modern convention and declares the HTTP verb the action accepts. Flush the cache and visit /helloworld.
Add a database table with declarative schema
Forget InstallSchema and UpgradeSchema — they were deprecated in 2.3. Describe the desired end state in etc/db_schema.xml and Magento works out the migration:
<?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="panth_helloworld" resource="default" engine="innodb">
<column xsi:type="int" name="entity_id" unsigned="true" nullable="false" identity="true"/>
<column xsi:type="varchar" name="title" nullable="false" length="255"/>
<column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="entity_id"/>
</constraint>
</table>
</schema>Run bin/magento setup:upgrade and the table appears. Declarative schema also generates db_schema_whitelist.json (run setup:db-declaration:generate-whitelist --module-name=Panth_HelloWorld), which records what your module is allowed to drop — without it, columns are never removed.
Declarative schema is not optional polish — it is the supported way to manage tables since 2.3. It gives you safe rollbacks and version-independent upgrades, and a missing whitelist is the most common reason a column change "does nothing" on deploy.
Change core behaviour with a plugin
The cardinal rule of Magento development: do not override core classes. To change what an existing method returns, use a plugin (interceptor) declared in etc/di.xml:
<?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="panth_helloworld_product_name"
type="Panth\HelloWorld\Plugin\ProductNamePlugin"/>
</type>
</config>The plugin class uses before, after, or around methods named for the target method:
<?php
declare(strict_types=1);
namespace Panth\HelloWorld\Plugin;
use Magento\Catalog\Model\Product;
class ProductNamePlugin
{
public function afterGetName(Product $subject, ?string $result): ?string
{
return $result ? $result . ' (Panth)' : $result;
}
}After any di.xml change run bin/magento cache:flush (and setup:di:compile in production mode). Prefer before/after plugins over around — around plugins carry a performance cost and are easy to get wrong.
The files at a glance
| File | Purpose | Required? |
|---|---|---|
registration.php | Registers the module with Magento | Yes |
etc/module.xml | Declares the name + load sequence | Yes |
etc/frontend/routes.xml | Maps a URL front name to your controllers | For pages |
etc/db_schema.xml | Declares tables (declarative schema) | For data |
etc/di.xml | Plugins, preferences, virtual types | For behaviour |
composer.json | Packaging + dependencies for distribution | To ship |
From here, the same building blocks scale up: a Block + view/frontend/templates for real layouts, events.xml + an observer for event hooks, acl.xml + system.xml for admin configuration, and a composer.json when you are ready to package and distribute. For a deeper look at building production extensions the modern way, see how to build a Magento 2 extension in the AI era.
Frequently asked questions
What is the minimum to create a Magento 2 module?
Two files: registration.php (registers the module via ComponentRegistrar) and etc/module.xml (declares the name and load sequence). Place them under app/code/<Vendor>/<Module>, then run bin/magento module:enable and setup:upgrade.
Where do I put a custom module — app/code or vendor?
Use app/code/<Vendor>/<Module> while developing. When you package the module for distribution or install it via Composer, it lands in vendor/. Never edit modules under vendor/ directly — extend them with plugins, preferences, or events instead.
Do I still use InstallSchema and UpgradeSchema?
No. Those were deprecated in Magento 2.3 in favour of declarative schema (etc/db_schema.xml), which describes the desired table state and lets Magento compute the migration with safe rollbacks. Use data patches (Setup/Patch/Data) for seeding data.
How do I change core behaviour without overriding the class?
Use a plugin (interceptor) declared in di.xml with a before, after, or around method. Plugins wrap public methods without replacing the class, so multiple modules can layer changes safely. Prefer before/after over around for performance.
My module changes do not show up — why?
Almost always caching or generated code. Run bin/magento cache:flush after config/XML changes, setup:upgrade after schema changes, and setup:di:compile in production mode after DI changes. If a column edit "does nothing", regenerate the db_schema_whitelist.json.
Need a production-grade Magento module built or reviewed? I build PSR-12, test-covered, Marketplace-ready extensions — fixed-fee from $499 audit · $2,499 sprint · ~Nh @ $25/hr. See extension development.
Get your module built right