Skip to main content Scroll Top
Back to insights
Magento May 26, 2026 8 mins read

Magento 2 Custom Module Guide 2026

Learn Magento 2 custom module development with routes, controllers, declarative schema, DI, plugins, ViewModels, REST API, and GraphQL using a practical Vendor_Blog example.

What You Will Build

Magento 2 custom module development becomes much easier when you understand why each file exists, not just where to place it. This guide walks through a practical module named Vendor_Blog, covering the core patterns used in real Magento and Adobe Commerce projects.

The example module

The module will include frontend routing, a controller, database schema, models, resource models, collections, dependency injection, plugins, ViewModels, REST API configuration, and GraphQL basics.

Why this matters

Many Magento module issues come from weak foundations: incorrect registration, poor dependency injection, overused preferences, missing database indexes, and unsafe template output. Getting these basics right saves hours during development and deployment.

Module Folder Structure

A Magento 2 module does not need every folder on day one. Start small, then add folders only when the feature needs them.

Minimum module structure

app/code/Vendor/Blog/ ├── etc/ │ └── module.xml └── registration.php

This is enough for Magento to detect and register an empty module.

Full structure for a practical module

app/code/Vendor/Blog/ ├── Api/ ├── Block/ ├── Controller/ ├── etc/ │ ├── module.xml │ ├── db_schema.xml │ ├── di.xml │ ├── events.xml │ ├── frontend/routes.xml │ └── webapi.xml ├── Model/ │ └── ResourceModel/ │ └── Post/ ├── Observer/ ├── Plugin/ ├── ViewModel/ ├── registration.php └── view/ └── frontend/ ├── layout/ └── templates/

Build toward this structure as the module grows. Avoid scaffolding unused files too early.

Register the Module

Magento needs two files to register a custom module: registration.php and etc/module.xml.

registration.php

<?php use Magento\Framework\Component\ComponentRegistrar;

ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Vendor_Blog',
DIR
);

module.xml




Enable the module

php bin/magento module:enable Vendor_Blog
php bin/magento setup:upgrade
php bin/magento cache:flush
php bin/magento module:status Vendor_Blog

If the module does not show as enabled, check that the module name matches exactly in both files.

Routes and Controllers

Routes connect a frontend URL to a controller class. In this example, /blog/index/index maps to Controller/Index/Index.php.

Frontend route configuration

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

Controller example

<?php declare(strict_types=1);

namespace Vendor\Blog\Controller\Index;

use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\View\Result\PageFactory;

class Index implements HttpGetActionInterface
{
public function __construct(private PageFactory $pageFactory)
{
}

public function execute(): ResultInterface
{
return $this->pageFactory->create();
}

}

Use HttpGetActionInterface for modern GET controllers instead of extending older action classes. If the route returns 404, flush the cache first.

Database Schema

Magento 2 uses declarative schema through db_schema.xml. The file describes the desired database state, and Magento applies the difference during setup:upgrade.

db_schema.xml example

<?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="vendor_blog_post" resource="default" engine="innodb" comment="Blog Posts"> <column xsi:type="int" name="post_id" unsigned="true" nullable="false" identity="true"/> <column xsi:type="varchar" name="title" nullable="false" length="255"/> <column xsi:type="text" name="content" nullable="true"/> <column xsi:type="smallint" name="is_active" nullable="false" default="1"/> <column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="post_id"/> </constraint> <index referenceId="VENDOR_BLOG_POST_IS_ACTIVE" indexType="btree"> <column name="is_active"/> </index> </table> </schema>

Apply schema changes

php bin/magento setup:upgrade

For production, generate the declarative schema whitelist before deployment.

php bin/magento setup:db-declaration:generate-whitelist --module-name=Vendor_Blog

Model, ResourceModel, and Collection

Magento separates entity logic, database access, and list queries into three classes: Model, ResourceModel, and Collection.

Model

<?php declare(strict_types=1);

namespace Vendor\Blog\Model;

use Magento\Framework\Model\AbstractModel;

class Post extends AbstractModel
{
protected function _construct(): void
{
$this->_init(ResourceModel\Post::class);
}
}

ResourceModel

_init('vendor_blog_post', 'post_id');
}
}

Collection

_init(Post::class, PostResource::class);
}

public function addActiveFilter(): self
{
return $this->addFieldToFilter('is_active', 1);
}

}

Adding reusable collection methods like addActiveFilter() keeps filtering consistent across controllers, ViewModels, APIs, and services.

Dependency Injection

Magento’s dependency injection system should provide your class dependencies through constructors. Avoid direct ObjectManager usage in custom modules.

Do not use ObjectManager directly

$objectManager = \Magento\Framework\App\ObjectManager::getInstance();

This hides dependencies, makes testing harder, and can cause deployment and compilation issues.

Use constructor injection

use Vendor\Blog\Model\ResourceModel\Post\CollectionFactory;

class PostProvider
{
public function __construct(private CollectionFactory $collectionFactory)
{
}

public function getActivePosts(): array
{
return $this->collectionFactory
->create()
->addActiveFilter()
->getItems();
}

}

Magento automatically generates factory classes in generated/code. You inject the factory; you do not manually create it.

Plugins, Preferences, and Observers

Plugins, preferences, observers, and virtual types are used for different extension patterns. Choosing the wrong one can create conflicts in large Magento projects.

Method What It Modifies Conflict Risk Best Use Case
Plugin Public method behavior Low Modify arguments or return values without replacing a class
Preference Entire class High Map your own interface to your own implementation
Observer Event-triggered side effect Very low Logging, external sync, notifications, and post-save actions
Virtual Type DI configuration only None Reuse a class with different constructor arguments

Plugin types

Type What It Does When to Use
before Changes method arguments Sanitize or modify input
after Changes method result Append or modify output
around Wraps the whole method Use sparingly because it is more expensive

Blocks, Templates, and ViewModels

Modern Magento frontend work should keep template logic clean. Use ViewModels to prepare data and templates to render safe output.

Layout XML with ViewModel

<block class="Magento\Framework\View\Element\Template" name="vendor.blog.list" template="Vendor_Blog::post/list.phtml"> <arguments> <argument name="view_model" xsi:type="object">Vendor\Blog\ViewModel\PostList</argument> </arguments> </block>

Template safety

Always escape output in .phtml templates and use translation helpers for user-facing strings. One unsafe output can become an XSS issue.

<h1><?= $escaper->escapeHtml(__('Blog Posts')) ?></h1>

escapeHtml($post->getData('title')) ?>

escapeHtml($post->getData('content')) ?>

REST API and GraphQL

Magento custom modules can expose data through REST using webapi.xml and through GraphQL using schema.graphqls.

REST API route

<route url="/V1/blog/post/:postId" method="GET"> <service class="Vendor\Blog\Api\PostRepositoryInterface" method="getById"/> <resources> <resource ref="anonymous"/> </resources> </route>

GraphQL schema

type Query { blogPosts: [BlogPost] @resolver(class: "Vendor\\Blog\\Model\\Resolver\\Posts") }

type BlogPost {
post_id: Int
title: String
content: String
}

GraphQL resolvers should return arrays, not raw model objects or collections. For list queries, add pagination with page size limits to avoid loading large collections in a single request.

Real Project Lessons

In real Magento 2 projects, the same custom module issues appear again and again. The biggest problems are usually not complex architecture problems. They are basic implementation shortcuts that become expensive later.

Avoid ObjectManager shortcuts

ObjectManager examples from old forums may work temporarily, but they create technical debt and can break compilation, testing, and maintainability.

Use plugins before preferences

Preferences replace full classes and often conflict with third-party extensions. Plugins are safer for modifying public method behavior without taking ownership of the whole class.

Index filtered columns

Fields used in filters, such as is_active, should be indexed. Missing indexes can turn simple queries into slow full table scans on large stores.

FAQ

How do I create a custom module in Magento 2?

Create registration.php and etc/module.xml, then run php bin/magento module:enable Vendor_ModuleName, setup:upgrade, and cache:flush. These two files are the minimum needed to register a Magento 2 module.

What is the difference between a plugin and an observer in Magento 2?

A plugin intercepts a public method and can modify its input or output. An observer listens to a Magento event and runs code after that event is dispatched. Use plugins for method-level changes and observers for event-driven side effects.

How does dependency injection work in Magento 2?

Magento reads constructor type hints and automatically injects the required class instances. Dependencies should be declared in the constructor, while preferences, plugins, virtual types, and argument overrides are configured in etc/di.xml.

Should I use a plugin or preference to override Magento core behavior?

Use a plugin whenever possible. Preferences replace the entire class and can conflict with other extensions. Plugins are safer because they modify behavior without replacing the full class.

Need Help With Magento Module Development?

If your Magento store needs a clean custom module, API integration, GraphQL resolver, or extension audit, working with Magento specialists can reduce rework and deployment risk.

Talk to a Magento Expert

Need a strong digital partner?

Planning your next product, platform, or growth move?

Ethnic Infotech helps teams shape scalable software, sharper customer experiences, and content systems that support real business growth.

Talk to our team Browse more articles
More insights

Keep reading

Privacy Preferences
When you visit our website, it may store information through your browser from specific services, usually in form of cookies. Here you can change your privacy preferences. Please note that blocking some types of cookies may impact your experience on our website and the services we offer.