Development of Custom Magento 2 Module
A custom module is the only correct way to add functionality to Magento 2. Direct code changes to the core or third-party modules guarantee problems during updates. A module isolates custom code and interacts with the platform through official extension points.
Module Structure
app/code/MyCompany/ModuleName/
├── Api/
│ ├── Data/
│ │ └── CustomEntityInterface.php # DTO interface
│ └── CustomEntityRepositoryInterface.php
├── Block/
│ └── Adminhtml/
│ └── CustomEntity/
│ └── Grid.php
├── Controller/
│ ├── Adminhtml/
│ │ └── CustomEntity/
│ │ ├── Index.php
│ │ └── Save.php
│ └── Index/
│ └── View.php
├── etc/
│ ├── module.xml
│ ├── di.xml
│ ├── acl.xml
│ ├── events.xml # event subscriptions
│ ├── adminhtml/
│ │ └── routes.xml
│ └── frontend/
│ ├── routes.xml
│ └── events.xml
├── Model/
│ ├── CustomEntity.php
│ ├── ResourceModel/
│ │ ├── CustomEntity.php
│ │ └── CustomEntity/
│ │ └── Collection.php
│ └── Repository/
│ └── CustomEntityRepository.php
├── Observer/
│ └── OrderPlaceAfter.php
├── Plugin/
│ └── ProductSavePlugin.php
├── Setup/
│ └── Patch/
│ ├── Schema/
│ │ └── CreateCustomEntityTable.php
│ └── Data/
│ └── AddDefaultData.php
├── view/
│ ├── adminhtml/
│ │ ├── layout/
│ │ └── templates/
│ └── frontend/
│ ├── layout/
│ └── templates/
├── composer.json
└── registration.php
Module Declaration
<!-- etc/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="MyCompany_ModuleName" setup_version="1.0.0">
<sequence>
<module name="Magento_Catalog"/>
<module name="Magento_Sales"/>
</sequence>
</module>
</config>
<?php
// registration.php
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'MyCompany_ModuleName',
__DIR__
);
Schema Patch — Creating a Table
<?php
// Setup/Patch/Schema/CreateCustomEntityTable.php
namespace MyCompany\ModuleName\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('mycompany_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('MyCompany Custom Entity Table');
$connection->createTable($table);
}
$setup->endSetup();
}
public static function getDependencies(): array { return []; }
public function getAliases(): array { return []; }
}
Model / ResourceModel / Collection
<?php
// Model/CustomEntity.php
namespace MyCompany\ModuleName\Model;
use Magento\Framework\Model\AbstractModel;
class CustomEntity extends AbstractModel
{
protected function _construct(): void
{
$this->_init(ResourceModel\CustomEntity::class);
}
}
<?php
// Model/ResourceModel/CustomEntity.php
namespace MyCompany\ModuleName\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
class CustomEntity extends AbstractDb
{
protected function _construct(): void
{
$this->_init('mycompany_custom_entity', 'entity_id');
}
}
<?php
// Model/ResourceModel/CustomEntity/Collection.php
namespace MyCompany\ModuleName\Model\ResourceModel\CustomEntity;
use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
class Collection extends AbstractCollection
{
protected function _construct(): void
{
$this->_init(
\MyCompany\ModuleName\Model\CustomEntity::class,
\MyCompany\ModuleName\Model\ResourceModel\CustomEntity::class
);
}
}
Observer — Event Subscription
<?php
// Observer/OrderPlaceAfter.php
namespace MyCompany\ModuleName\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 \MyCompany\ModuleName\Model\CustomEntityFactory $entityFactory,
private readonly \MyCompany\ModuleName\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(),
]);
}
}
}
<!-- etc/events.xml -->
<config>
<event name="sales_order_place_after">
<observer name="mycompany_order_place_after"
instance="MyCompany\ModuleName\Observer\OrderPlaceAfter"/>
</event>
</config>
Plugin (Interceptor)
<?php
// Plugin/ProductSavePlugin.php
namespace MyCompany\ModuleName\Plugin;
use Magento\Catalog\Model\Product;
class ProductSavePlugin
{
// Before — executes before method, can change arguments
public function beforeSave(Product $subject): void
{
if (!$subject->getData('custom_field')) {
$subject->setData('custom_field', 'default_value');
}
}
// After — executes after method, gets result
public function afterSave(Product $subject, Product $result): Product
{
// Invalidate custom cache when saving product
// ...
return $result;
}
// Around — full control, calling $proceed() is mandatory
// Use only when before/after are not suitable
}
<!-- etc/di.xml -->
<type name="Magento\Catalog\Model\Product">
<plugin name="mycompany_product_save_plugin"
type="MyCompany\ModuleName\Plugin\ProductSavePlugin"
sortOrder="10"
disabled="false"/>
</type>
Console Command
<?php
// Console/Command/SyncDataCommand.php
namespace MyCompany\ModuleName\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputOption;
class SyncDataCommand extends Command
{
protected function configure(): void
{
$this->setName('mycompany:sync-data')
->setDescription('Synchronize data with external system')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Check only without writing');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$isDryRun = $input->getOption('dry-run');
$output->writeln('<info>Starting synchronization...</info>');
// synchronization logic
if (!$isDryRun) {
$output->writeln('<info>Data written</info>');
} else {
$output->writeln('<comment>Dry-run mode, data not written</comment>');
}
return Command::SUCCESS;
}
}
Registration in di.xml:
<type name="Magento\Framework\Console\CommandList">
<arguments>
<argument name="commands" xsi:type="array">
<item name="mycompany_sync_data" xsi:type="object">
MyCompany\ModuleName\Console\Command\SyncDataCommand
</item>
</argument>
</arguments>
</type>
Run: bin/magento mycompany:sync-data --dry-run
Testing
# Unit tests
vendor/bin/phpunit -c dev/tests/unit/phpunit.xml app/code/MyCompany/ModuleName/Test/Unit/
# Integration tests (requires separate database)
vendor/bin/phpunit -c dev/tests/integration/phpunit.xml \
app/code/MyCompany/ModuleName/Test/Integration/
Timeline
Simple module (new table + CRUD in admin + observer): 3–5 days. Module with REST API, Repository pattern, unit tests and Admin Grid: 1–2 weeks. Complex module (external API integration, queues, GraphQL extension, full testing): 3–6 weeks.







