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.







