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.phpThis 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_BlogIf 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:upgradeFor production, generate the declarative schema whitelist before deployment.
php bin/magento setup:db-declaration:generate-whitelist --module-name=Vendor_BlogModel, 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.
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.

