Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Magento 2 Custom Module Development for Beginners

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.

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.

registration.phpComponentRegistraretc/module.xmlSequence & VersionComponent RegistrarModule List LoadedDI CompilerGenerated Classesbin/magento setup:upgrade → setup:di:compile
Magento 2 module registration pipeline from static files to compiled dependency injection container

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.

Browser Request/helloworld/index/viewFront ControllerDispatch LoopRoute Resolverroutes.xml MatchController ActionExecute() MethodPage ResultLayout Rendered
Frontend request lifecycle in Magento 2 showing route resolution and controller dispatch

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.

MechanismBest ForRisk LevelUpgrade Safety
PreferenceReplacing core behavior entirelyHighLow — conflicts with other modules overriding same class
ObserverReacting to dispatched events (save, load, send)MediumMedium — depends on event stability
Plugin (Interceptor)Modifying input/output of public methodsLowHigh — composable, multiple plugins can coexist
Event DispatchExposing hooks in your own moduleNoneN/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.

Need to Modify Core?Change Method I/O?React to Event?Replace Entire Class?Use PluginUse ObserverUse PreferenceYesEvent ExistsFull Replace
Decision framework for selecting Magento 2 extension mechanisms safely

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.xml or adding plugins, run bin/magento setup:di:compile. In production mode, missing generated interceptors cause fatal errors.
  • Hardcoding store/locale assumptions: Use StoreManagerInterface and 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.xml entries. 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.

Frequently Asked Questions

All custom code lives in app/code/Vendor/ModuleName. You need registration.php at the root and etc/module.xml to declare the module sequence. Subdirectories like Controller, Model, Block, and view follow PSR-4 autoloading standards strictly. Never place custom modules in vendor or lib; those are reserved for Composer packages and core framework libraries respectively.

Run bin/magento module:enable Vendor_ModuleName followed by bin/magento setup:upgrade to register it in the database. Verify installation status using bin/magento module:status Vendor_ModuleName. If changes do not appear, clear generated code and caches with bin/magento setup:di:compile and cache:flush, especially when running in production mode where static content is cached aggressively.

Plugins intercept public methods to modify input, output, or execution flow without altering core code. Observers react to dispatched events after specific actions occur. Use plugins for modifying existing functionality like price calculation or validation logic. Use observers for side effects like logging, sending emails, or syncing external systems when business events trigger, keeping concerns separated from core method execution.

Core modifications break upgrade paths and violate Adobe Commerce support policies. Every update overwrites changed files, losing your work and introducing regression risks. Custom modules, plugins, and preferences isolate your logic safely. In my experience maintaining eCommerce sites since 2010, stores with direct core edits require expensive remediation during security patches. Always extend through approved extension points to ensure long-term maintainability and upgrade compatibility.

Magento 2 uses constructor injection defined in di.xml or automatically resolved via type hints. The ObjectManager instantiates classes with their declared dependencies. Avoid using ObjectManager::getInstance() directly in code except in factories or proxies. Define virtual types and preferences in etc/di.xml to swap implementations. This pattern enables testability and loose coupling, which matters significantly as module complexity grows beyond simple CRUD operations.

Missing registration.php, incorrect namespace casing, or wrong module name in module.xml causes this. Ensure registration.php uses ComponentRegistrar::MODULE with exact vendor_module string matching etc/module.xml name attribute. File permissions on app/code directories must allow PHP-FPM read access. On Ubuntu servers I manage, ownership issues after git deployments frequently cause silent failures. Verify composer.json autoload sections if using Composer-based module installation instead of manual placement.

UI components suit complex admin grids, forms with dynamic fields, and data-bound interfaces requiring Knockout.js integration. Traditional blocks and phtml templates remain better for simple frontend rendering, custom pages, or lightweight widgets. UI components add significant JavaScript overhead and debugging complexity. For most beginner custom modules handling basic CRUD or display logic, start with blocks. Migrate to UI components only when admin interface requirements demand reactive data binding or extensive configuration options.

Simple modules range Rs 25,000–60,000 (~USD 190–450). Complex integrations or checkout modifications cost Rs 80,000–200,000+ (~USD 600–1,500+). Pricing depends on scope, testing requirements, and documentation needs. Agencies charge more than freelancers. Budget projects often underestimate QA time; Magento's EAV architecture and caching layers require thorough testing across store views and customer groups before production deployment.

Validate all inputs server-side using Magento validators, never trust client data. Escape output in templates using $block->escapeHtml() to prevent XSS. Use CSRF tokens for form submissions. Restrict admin routes with ACL resources in etc/acl.xml. Sanitize file uploads and validate MIME types. Store secrets in env.php, never hardcode credentials. SQL queries must use prepared statements through resource models. These basics prevent vulnerabilities that compromise entire storefronts and customer data.

Enable developer mode with bin/magento deploy:mode:set developer to see detailed errors. Use Xdebug with PhpStorm for step-through debugging. Log to var/log/custom.log via Psr\Log\LoggerInterface instead of echo statements. Inspect generated classes in generated/metadata and generated/code to understand DI compilation results. Disable full-page cache temporarily when troubleshooting layout or block issues. Database queries can be logged by enabling query logging in env.php for performance investigation during development cycles.

Technically possible but impractical. Magento's DI compilation, plugin system, and EAV models require runtime context to validate correctly. Unit tests can run isolated, but integration testing needs a working instance. Use Docker with official Magento Cloud Docker or Mark Shust's setup for consistent environments. Developing blind leads to deployment surprises. In production work, I have seen modules pass local syntax checks yet fail catastrophically due to missing DI configurations only discoverable in running instances.

Magento 2.4.7 requires PHP 8.2 minimum, with 8.3 supported. PHP 8.4 is not yet compatible as of current stable releases. Your custom modules must follow PHP 8.2+ syntax including typed properties, union types, and readonly classes where appropriate. Test against the exact PHP version your production server runs. Version mismatches between development and production cause subtle type errors and deprecation warnings that surface only after deployment.

Use declarative schema in etc/db_schema.xml for Magento 2.4+ instead of InstallSchema/UpgradeSetup scripts. Define tables, columns, indexes, and constraints declaratively. Run bin/magento setup:db-declaration:generate-whitelist to create db_schema_whitelist.json tracking allowed changes. This approach supports rollback and diff detection. Legacy upgrade scripts still execute but declarative schema is now standard. Always backup databases before running setup:upgrade in production, as schema changes are irreversible without restore points.

Start with PHPUnit unit tests for models, helpers, and service classes mocking dependencies. Add integration tests for repository patterns, API endpoints, and database interactions using Magento's TestFramework. Functional tests using MFTF validate admin and storefront workflows. Do not skip testing; Magento's complexity hides bugs until edge cases surface in production. Even basic test coverage catches DI misconfigurations and type errors early. Aim for critical path coverage first rather than chasing percentage metrics.

Package as a Composer repository using Satis or private Packagist. Create proper composer.json with type magento2-module, specify version constraints, and define autoload mappings. Tag releases semantically in Git. Client projects install via composer require vendor/module-name. This enables version pinning, dependency resolution, and clean updates. Manual ZIP uploads work for single sites but become unmanageable at scale. For agencies managing multiple stores, Composer distribution prevents version drift and simplifies security patching across installations.

Share this article

Quick Contact Options
Choose how you want to connect me: