
August 13, 2026
9 min read
Table of Contents
By Kokil Thapa | Last reviewed: August 2026
Starting Magento 2 custom module development for beginners often feels overwhelming because the platform relies heavily on conventions, XML configuration, and dependency injection rather than explicit routing files. If you are transitioning from frameworks like Laravel or Symfony, or perhaps looking to extend an existing store beyond what configuration allows, understanding the foundational architecture is critical before writing business logic. This guide walks through building a functional module from scratch, focusing on the patterns that actually work in production environments running Magento 2.4.7+ and PHP 8.3/8.4 in 2026.
registration.php file and etc/module.xml to register the namespace, then defining routes, controllers, and layouts via XML. Modern modules rely on constructor dependency injection and view models rather than direct object instantiation or helper classes.Before diving into code, it helps to understand where modules fit in the broader ecosystem. Unlike simpler CMS platforms where you might drop a PHP file into a folder and include it, Magento uses an autoloader and a compiled application state. For developers exploring eCommerce website development in Nepal or globally, mastering this structure prevents upgrade-breaking hacks later. You should also be comfortable with Composer, as even local modules are typically managed as packages or symlinked via path repositories during development.
How do you register a new module in Magento 2?
Every Magento 2 module requires two mandatory files to be recognized by the system: registration.php and etc/module.xml. Without these, the framework ignores your code entirely regardless of its location. The standard directory structure follows the Vendor_ModuleName convention under app/code/.
Create the registration entry point
The registration.php file tells the component registrar about your module's existence. Place this at the root of your module directory (app/code/Kokil/HelloWorld/registration.php):
<?php
declare(strict_types=1);
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Kokil_HelloWorld',
__DIR__
); Define module metadata and sequence
Next, create etc/module.xml. This file declares the module name and optionally sets load order dependencies. In 2026, always specify a setup_version for legacy compatibility, though declarative schema has replaced versioned install scripts for database changes.
<?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="Kokil_HelloWorld" setup_version="1.0.0">
<sequence>
<module name="Magento_Store"/>
</sequence>
</module>
</config> After creating these files, run bin/magento module:enable Kokil_HelloWorld followed by bin/magento setup:upgrade. On production servers with read-only filesystems, ensure generated code and caches are cleared properly. A common mistake I've seen on client projects is forgetting to run setup:di:compile in production mode, which leaves the application unable to resolve injected dependencies.
What is the correct way to use dependency injection in Magento 2?
Beginners often reach for the ObjectManager directly or use Helper classes to share logic. Both patterns are discouraged in modern Magento 2 development. Instead, define service contracts (interfaces) and inject concrete implementations via constructors. This makes modules testable, replaceable, and compatible with Magento's compilation process.
Define a service contract interface
Create Api/GreetingInterface.php to abstract the business logic:
<?php
declare(strict_types=1);
namespace Kokil\HelloWorld\Api;
interface GreetingInterface
{
public function getMessage(): string;
} Implement the service class
Create Model/Greeting.php implementing the interface. Inject only what you need — avoid injecting entire managers when a repository or specific service suffices.
<?php
declare(strict_types=1);
namespace Kokil\HelloWorld\Model;
use Kokil\HelloWorld\Api\GreetingInterface;
class Greeting implements GreetingInterface
{
public function getMessage(): string
{
return __('Welcome to our custom Magento 2 module!');
}
} Configure preference in di.xml
Tell Magento which class implements the interface by adding 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">
<preference for="Kokil\HelloWorld\Api\GreetingInterface"
type="Kokil\HelloWorld\Model\Greeting" />
</config> This pattern mirrors service container bindings in Laravel or Symfony but is strictly XML-driven. On real client projects, I've found that enforcing interfaces early prevents tight coupling when requirements shift — especially in legal-tech portals where document generation logic frequently changes between notary, marriage, and attestation workflows.
How do you create a frontend controller and route?
Magento 2 uses a front controller pattern where all requests enter through pub/index.php and are dispatched based on route configuration. To expose a URL like /helloworld/index/view, you need both a route definition and a controller class.
Declare the frontend route
Create 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="kokil_helloworld" frontName="helloworld">
<module name="Kokil_HelloWorld" />
</route>
</router>
</config> Build the controller action
Controllers implement HttpActionInterface and return a ResultInterface. Never echo output directly. Create Controller/Index/View.php:
<?php
declare(strict_types=1);
namespace Kokil\HelloWorld\Controller\Index;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\PageFactory;
use Magento\Framework\View\Result\Page;
class View implements HttpGetActionInterface
{
private PageFactory $pageFactory;
public function __construct(PageFactory $pageFactory)
{
$this->pageFactory = $pageFactory;
}
public function execute(): Page
{
/** @var Page $page */
$page = $this->pageFactory->create();
$page->getConfig()->getTitle()->set(__('Hello World Module'));
return $page;
}
} Note the use of HttpGetActionInterface instead of extending the deprecated Action base class. This interface-based approach is required for HTTP method-specific routing in Magento 2.4.7+. Always type-hint factories rather than concrete result objects to maintain testability.
How does layout XML connect controllers to templates?
Controllers don't render HTML directly. They return a Page result object that triggers layout processing. Layout XML maps handles to blocks and templates, keeping presentation separate from logic. This separation is non-negotiable for maintainable Magento 2 custom module development for beginners.
Create the layout handle file
The filename must match the full action path: view/frontend/layout/kokil_helloworld_index_view.xml.
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
layout="1column"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="content">
<block class="Kokil\HelloWorld\Block\Hello"
name="kokil.hello.world"
template="Kokil_HelloWorld::hello.phtml" />
</referenceContainer>
</body>
</page> Build a block class with ViewModel pattern
Modern Magento 2 favors ViewModels over embedding logic in blocks. Create Block/Hello.php:
<?php
declare(strict_types=1);
namespace Kokil\HelloWorld\Block;
use Magento\Framework\View\Element\Template;
use Magento\Framework\View\Element\Template\Context;
use Kokil\HelloWorld\ViewModel\Hello as HelloViewModel;
class Hello extends Template
{
private HelloViewModel $viewModel;
public function __construct(
Context $context,
HelloViewModel $viewModel,
array $data = []
) {
parent::__construct($context, $data);
$this->viewModel = $viewModel;
}
public function getViewModel(): HelloViewModel
{
return $this->viewModel;
}
} The corresponding ViewModel (ViewModel/Hello.php) contains pure presentation logic and is injected separately. Templates access it via $block->getViewModel()->getMessage(). This keeps blocks thin and view logic testable without mocking the entire layout system.
When should you use plugins versus observers versus preferences?
Choosing the right extension mechanism determines whether your module survives upgrades. Preferences override entire classes, observers react to events, and plugins intercept method calls. Each has trade-offs.
| Mechanism | Best For | Risk Level | Upgrade Safety |
|---|---|---|---|
| Preference | Replacing core behavior entirely | High | Low — conflicts with other modules overriding same class |
| Observer | Reacting to dispatched events (save, load, send) | Medium | Medium — depends on event stability |
| Plugin (Interceptor) | Modifying input/output of public methods | Low | High — composable, multiple plugins can coexist |
| Event Dispatch | Exposing hooks in your own module | None | N/A — you control the contract |
In practice, prefer plugins for modifying core functionality. Use observers only when no plugin point exists and the event is documented as stable. Reserve preferences for cases where you must replace an entire service implementation — and always check if a virtual type or argument replacement suffices instead. On eCommerce projects handling payment callbacks or cart calculations, I've debugged countless issues caused by conflicting preferences that could have been solved with before/after plugins.
What are common pitfalls in Magento 2 module development?
Even experienced PHP developers stumble on Magento-specific gotchas. Avoiding these saves hours of debugging during deployment and upgrades.
- Using ObjectManager directly: Always inject dependencies via constructor. Direct ObjectManager usage bypasses DI compilation and breaks testability.
- Ignoring generated code: After changing
di.xmlor adding plugins, runbin/magento setup:di:compile. In production mode, missing generated interceptors cause fatal errors. - Hardcoding store/locale assumptions: Use
StoreManagerInterfaceand translation functions (__()). Nepal-based stores serving multilingual content (Nepali/English) require proper locale handling for dates, currency (NPR), and number formatting. - Skipping ACL rules: Admin modules need
etc/acl.xmlentries. Without them, admin users cannot access configuration or menu items even if routes exist. - Overusing setup scripts: Declarative schema (
db_schema.xml) replaced versioned install/upgrade scripts in 2.3+. Legacy scripts still run but new table/column definitions belong in declarative XML. - Neglecting caching tags: Custom blocks displaying dynamic data must declare cache tags and identities. Otherwise, full-page cache serves stale content after entity updates.
For developers evaluating whether Magento fits their project compared to lighter alternatives, reviewing Shopify vs WooCommerce comparisons provides useful context on complexity trade-offs. Magento's power comes with operational overhead that only pays off at scale or when deep customization is required.
Start Building Your First Magento 2 Module Today
Magento 2 custom module development for beginners becomes manageable once you internalize the registration-di-layout trinity and resist shortcuts that compromise upgrade safety. Start with the hello world example above, then incrementally add features: a custom admin grid, a REST API endpoint, or a checkout observer. Test each addition against a fresh Magento installation with sample data to catch assumptions early. When you're ready to build production extensions for real commerce workflows, reach out via the contact page to discuss architecture, performance implications, or migration strategies tailored to your business needs.

