Custom Magento 2 Module Development
Directly editing Magento 2 core code during customization is a guaranteed path to update errors. Typical scenario: your store runs Magento 2.4.5, you edit app/code/Magento/Sales/Model/Order.php, six months later a security patch 2.4.6 is released, and your modifications break the update. A custom module isolates business logic and interacts with the platform through official extension points: Events, Observers, Plugins (Interceptors), DI, and preferences. This allows seamless Magento updates without losing functionality. Want to avoid these issues? Contact us for a consultation — we'll help design the right architecture.
We have been developing custom Magento 2 modules for over 5 years. During this time, we have encountered many common issues — from N+1 queries in Observers to incorrect module sequence — and have developed optimal architectural solutions. Below, using the example of creating a module that records custom data during order placement and synchronizes it with an external system, we will break down best practices.
How to Create a Magento 2 Module from Scratch?
Let's break down the structure of a typical module step by step.
Step 1: Skeleton Generation
Use bin/magento generate:module or manually create the directory app/code/Vendor/Module. Required files:
-
registration.php -
etc/module.xml -
composer.json
Example 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="Vendor_Module" setup_version="1.0.0">
<sequence>
<module name="Magento_Sales"/>
<module name="Magento_Catalog"/>
</sequence>
</module>
</config>
Step 2: Schema Patches — Creating Tables
Instead of outdated Install/Upgrade scripts, Magento recommends Schema Patches. These are atomic changes applied once. Example of creating a table with a foreign key to catalog_product_entity:
<?php
// Setup/Patch/Schema/CreateCustomEntityTable.php
namespace Vendor\Module\Setup\Patch\Schema;
use Magento\Framework\DB\Ddl\Table;
use Magento\Framework\Setup\Patch\SchemaPatchInterface;
use Magento\Framework\Setup\SchemaSetupInterface;
class CreateCustomEntityTable implements SchemaPatchInterface
{
public function __construct(
private readonly SchemaSetupInterface $schemaSetup
) {}
public function apply(): void
{
$setup = $this->schemaSetup;
$setup->startSetup();
$connection = $setup->getConnection();
$tableName = $setup->getTable('vendor_custom_entity');
if (!$connection->isTableExists($tableName)) {
$table = $connection->newTable($tableName)
->addColumn('entity_id', Table::TYPE_INTEGER, null, [
'identity' => true,
'nullable' => false,
'primary' => true,
'unsigned' => true,
], 'Entity ID')
->addColumn('product_id', Table::TYPE_INTEGER, null, [
'unsigned' => true,
'nullable' => false,
], 'Product ID')
->addColumn('custom_value', Table::TYPE_DECIMAL, '12,4', [
'nullable' => false,
'default' => '0.0000',
], 'Custom Value')
->addColumn('status', Table::TYPE_SMALLINT, null, [
'nullable' => false,
'default' => 1,
], 'Status')
->addColumn('created_at', Table::TYPE_TIMESTAMP, null, [
'nullable' => false,
'default' => Table::TIMESTAMP_INIT,
], 'Created At')
->addColumn('updated_at', Table::TYPE_TIMESTAMP, null, [
'nullable' => false,
'default' => Table::TIMESTAMP_INIT_UPDATE,
], 'Updated At')
->addForeignKey(
$setup->getFkName($tableName, 'product_id', 'catalog_product_entity', 'entity_id'),
'product_id',
$setup->getTable('catalog_product_entity'),
'entity_id',
Table::ACTION_CASCADE
)
->addIndex($setup->getIdxName($tableName, ['status']), ['status'])
->setComment('Vendor Custom Entity Table');
$connection->createTable($table);
}
$setup->endSetup();
}
public static function getDependencies(): array { return []; }
public function getAliases(): array { return []; }
}
Step 3: Observer and Plugin — Event Reactions
A typical task is to record additional information into a custom table when an order is created. Use an Observer on the sales_order_place_after event:
<?php
// Observer/OrderPlaceAfter.php
namespace Vendor\Module\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Psr\Log\LoggerInterface;
class OrderPlaceAfter implements ObserverInterface
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly \Vendor\Module\Model\CustomEntityFactory $entityFactory,
private readonly \Vendor\Module\Model\ResourceModel\CustomEntity $entityResource,
) {}
public function execute(Observer $observer): void
{
/** @var \Magento\Sales\Model\Order $order */
$order = $observer->getEvent()->getOrder();
try {
foreach ($order->getAllVisibleItems() as $item) {
$entity = $this->entityFactory->create();
$entity->setData([
'product_id' => (int)$item->getProductId(),
'custom_value' => $item->getQtyOrdered(),
'status' => 1,
]);
$this->entityResource->save($entity);
}
} catch (\Exception $e) {
$this->logger->error('OrderPlaceAfter observer error: ' . $e->getMessage(), [
'order_id' => $order->getId(),
]);
}
}
}
To modify the behavior of existing classes, use Plugin (Interceptor). For example, set a default value for a custom field before saving a product:
<?php
// Plugin/ProductSavePlugin.php
namespace Vendor\Module\Plugin;
use Magento\Catalog\Model\Product;
class ProductSavePlugin
{
public function beforeSave(Product $subject): void
{
if (!$subject->getData('custom_field')) {
$subject->setData('custom_field', 'default_value');
}
}
public function afterSave(Product $subject, Product $result): Product
{
// Invalidate custom cache on product save
return $result;
}
}
Why Plugin is Better than Preference?
Plugin allows you to modify only specific methods without overriding the entire class. This reduces code volume by 40% and lowers conflict risk with other modules. Preference replaces the entire class, which can cause issues with multiple overrides. Magento DevDocs: "Plugins are the primary way to extend Magento's behavior."
| Feature | Plugin | Observer | Preference |
|---|---|---|---|
| Scope | Specific method | Event | Entire class |
| Flexibility | High (before/after/around) | Medium (after only) | Low (full replacement) |
| Performance | High (only when called) | Medium (always loaded) | High |
| Conflicts | Minimal | Low | High |
How to Test a Magento 2 Module?
Testing is mandatory. Unit tests validate logic in isolation; integration tests verify database and external service interactions. We cover at least 70% of code with tests. This prevents regression and increases module reliability. Use PHPUnit and Magento Testing Framework.
Common Errors in Magento 2 Module Development
- Incorrect module sequence (sequence) — leads to installation errors.
- Ignoring Core Web Vitals and performance — for example, N+1 queries through a loop in Observer.
- Unnecessary use of around-plugins — they increase complexity by 30%.
- Lack of tests — the module becomes a black box.
- Storing sensitive data in code — use config.php or environment variables.
What's Included in the Work
We develop a custom module turnkey:
- Module declaration, composer.json, registration.php.
- Setup Patches for schema and data.
- API interfaces and Repository pattern for entity operations.
- Observer and Plugin for integration with Magento events.
- Admin Grids and forms for data management.
- Unit and Integration tests covering the logic (at least 70% lines).
- Documentation: functional description, installation guide.
- Repository access handover and support.
| Module Type | Approximate Timeline | What's Included |
|---|---|---|
| Simple | 3–5 days | Table, CRUD, Observer, basic Admin Grid |
| Medium | 1–2 weeks | Repository, REST API, tests, full Admin |
| Complex | 3–6 weeks | External integration, queues, GraphQL, production testing |
Case Study: Order Sync with ERP
From our practice: a client with an online store processing 5000 orders per day needed to transfer data to their accounting system without manual duplication. We developed a module with an Observer on sales_order_place_after, writing data to a custom table, and a Console Command for background sending. We used a Plugin to add a transfer status in the admin panel. The solution handled a load of 1000+ orders per hour without failures, reducing labor costs by 80%. If you need a similar integration, contact us for a consultation.
Timelines and Indicative Cost
Timelines vary: simple module — 3 to 5 days, medium — 1–2 weeks, complex — 3–6 weeks. Cost is calculated individually after requirements analysis. Contact us for a project estimate — we'll propose the optimal solution.
We have been working with Magento for over 5 years and have implemented 50+ custom modules for various tasks. Our developers are certified and proficient in the full Magento 2 stack. We guarantee quality and adherence to deadlines. Get a consultation on custom Magento 2 module development — fill out the feedback form, and we'll get back to you within a day.







