Разработка кастомного модуля Magento 2
Кастомный модуль — единственный правильный способ добавить функциональность в Magento 2. Прямое изменение кода ядра или сторонних модулей гарантирует проблемы при обновлениях. Модуль изолирует кастомный код и взаимодействует с платформой через официальные точки расширения.
Структура модуля
app/code/MyCompany/ModuleName/
├── Api/
│ ├── Data/
│ │ └── CustomEntityInterface.php # DTO интерфейс
│ └── 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 # подписки на события
│ ├── 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
Декларация модуля
<!-- 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 — создание таблицы
<?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 — подписка на событие
<?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 — выполняется до метода, может изменить аргументы
public function beforeSave(Product $subject): void
{
if (!$subject->getData('custom_field')) {
$subject->setData('custom_field', 'default_value');
}
}
// After — выполняется после метода, получает результат
public function afterSave(Product $subject, Product $result): Product
{
// Инвалидация кастомного кеша при сохранении продукта
// ...
return $result;
}
// Around — полный контроль, вызов $proceed() обязателен
// Использовать только когда before/after не подходят
}
<!-- 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('Синхронизация данных с внешней системой')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Только проверка без записи');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$isDryRun = $input->getOption('dry-run');
$output->writeln('<info>Начало синхронизации...</info>');
// логика синхронизации
if (!$isDryRun) {
$output->writeln('<info>Данные записаны</info>');
} else {
$output->writeln('<comment>Dry-run режим, данные не записаны</comment>');
}
return Command::SUCCESS;
}
}
Регистрация в 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>
Запуск: bin/magento mycompany:sync-data --dry-run
Тестирование
# Unit tests
vendor/bin/phpunit -c dev/tests/unit/phpunit.xml app/code/MyCompany/ModuleName/Test/Unit/
# Integration tests (требует отдельную БД)
vendor/bin/phpunit -c dev/tests/integration/phpunit.xml \
app/code/MyCompany/ModuleName/Test/Integration/
Сроки
Простой модуль (новая таблица + CRUD в admin + observer): 3–5 дней. Модуль с REST API, Repository pattern, unit-тестами и Admin Grid: 1–2 недели. Сложный модуль (интеграция с внешним API, очереди, GraphQL расширение, полноценное тестирование): 3–6 недель.







