DEV Community

Magevanta
Magevanta

Posted on • Originally published at magevanta.com

Magento 2 Module Development Best Practices in 2026

Writing Magento 2 modules that don't break stores, don't slow down performance, and survive upgrades requires discipline. After working on production Magento codebases, here are the patterns that matter most.

Service contracts: the right way to expose functionality

Never depend directly on concrete model classes. Always use interfaces (service contracts):

// Bad: depends on a concrete class
public function __construct(
    \Magento\Catalog\Model\Product $product
) {}

// Good: depends on an interface
public function __construct(
    \Magento\Catalog\Api\ProductRepositoryInterface $productRepository
) {}
Enter fullscreen mode Exit fullscreen mode

Why it matters: concrete models can be replaced or overridden in customized Magento installations. Depending on interfaces makes your module resilient to those changes.

If your module exposes its own functionality, define interfaces first:

// Api/MyModuleServiceInterface.php
interface MyModuleServiceInterface
{
    public function process(int $entityId): ResultInterface;
}

// Model/MyModuleService.php
class MyModuleService implements MyModuleServiceInterface
{
    public function process(int $entityId): ResultInterface { ... }
}
Enter fullscreen mode Exit fullscreen mode

Plugins vs. observers: choosing correctly

Both plugins and observers let you hook into Magento's core behavior. Choosing wrong creates bugs and performance problems.

Use observers when:

  • You're reacting to something that already happened (after event)
  • Multiple modules might observe the same event (no conflict)
  • You don't need to modify the return value
// Good observer usage: react to order placement
public function execute(\Magento\Framework\Event\Observer $observer): void
{
    $order = $observer->getOrder();
    $this->notificationService->notifyNewOrder($order->getId());
}
Enter fullscreen mode Exit fullscreen mode

Use plugins when:

  • You need to modify input parameters (before plugin)
  • You need to modify the return value (after plugin)
  • You need to wrap execution (around plugin)
// Good plugin usage: add data to a repository result
public function afterGetById(
    ProductRepositoryInterface $subject,
    ProductInterface $result,
    int $productId
): ProductInterface {
    $result->setCustomData($this->getCustomData($productId));
    return $result;
}
Enter fullscreen mode Exit fullscreen mode

Avoid around plugins unless necessary. Around plugins intercept the entire method call and are significantly slower than before/after plugins. They also make debugging harder.

Dependency injection done right

Magento's DI system is powerful but easy to misuse.

Use constructor injection for required dependencies:

public function __construct(
    private readonly LoggerInterface $logger,
    private readonly CacheInterface $cache,
    private readonly ProductRepositoryInterface $productRepository,
) {}
Enter fullscreen mode Exit fullscreen mode

Use factories for objects you create on-the-fly:

public function __construct(
    private readonly \Magento\Catalog\Model\ProductFactory $productFactory,
) {}

public function createProduct(): Product
{
    return $this->productFactory->create();
}
Enter fullscreen mode Exit fullscreen mode

Inject the ObjectManager directly: never. It bypasses DI, makes testing impossible, and creates hidden dependencies.

Performance-conscious coding

Avoid loading product/order collections in loops:

// Bad: O(n) queries
foreach ($orderIds as $orderId) {
    $order = $this->orderRepository->get($orderId); // query per iteration
    $this->process($order);
}

// Good: load all at once
$searchCriteria = $this->searchCriteriaBuilder
    ->addFilter('entity_id', $orderIds, 'in')
    ->create();
$orders = $this->orderRepository->getList($searchCriteria);
foreach ($orders->getItems() as $order) {
    $this->process($order);
}
Enter fullscreen mode Exit fullscreen mode

Cache expensive operations:

public function getExpensiveData(int $id): array
{
    $cacheKey = 'my_module_data_' . $id;
    $cached = $this->cache->load($cacheKey);

    if ($cached !== false) {
        return unserialize($cached);
    }

    $data = $this->runExpensiveCalculation($id);
    $this->cache->save(serialize($data), $cacheKey, ['my_module_tag'], 3600);

    return $data;
}
Enter fullscreen mode Exit fullscreen mode

Use indexers for frequently-queried derived data:

If you're computing something on every page load that could be pre-computed (like product scores, custom pricing, or aggregated stats), build an indexer. Compute once, read many times.

Testing your module

Untested Magento modules break in production. At minimum:

Unit tests for business logic:

class MyServiceTest extends TestCase
{
    public function testProcessReturnsSuccessResult(): void
    {
        $service = new MyService(
            $this->createMock(LoggerInterface::class),
            $this->createMock(CacheInterface::class),
        );

        $result = $service->process(1);

        $this->assertTrue($result->isSuccess());
    }
}
Enter fullscreen mode Exit fullscreen mode

Integration tests for database operations and DI-heavy code:

class MyRepositoryTest extends \Magento\TestFramework\TestCase\AbstractController
{
    public function testSaveAndLoad(): void
    {
        $repository = $this->_objectManager->get(MyRepositoryInterface::class);
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Aim for >60% test coverage on your business logic. Models and repositories are the most important to test.

Schema and data patches over upgrade scripts

Old-style UpgradeSchema.php and UpgradeData.php scripts are deprecated. Use declarative schema and data patches:

<!-- etc/db_schema.xml -->
<table name="my_module_entity" resource="default" engine="innodb">
    <column name="entity_id" xsi:type="int" unsigned="true" nullable="false" identity="true"/>
    <column name="name" xsi:type="varchar" length="255" nullable="false"/>
    <column name="created_at" xsi:type="timestamp" default="CURRENT_TIMESTAMP"/>
    <constraint xsi:type="primary" referenceId="PRIMARY">
        <column name="entity_id"/>
    </constraint>
</table>
Enter fullscreen mode Exit fullscreen mode
// Setup/Patch/Data/AddDefaultConfiguration.php
class AddDefaultConfiguration implements DataPatchInterface
{
    public function apply(): void
    {
        $this->configWriter->save('my_module/general/enabled', '1');
    }

    public static function getDependencies(): array { return []; }
    public function getAliases(): array { return []; }
}
Enter fullscreen mode Exit fullscreen mode

Patches run once and are tracked in patch_list table. No more version comparison logic.

Module structure that scales

app/code/Vendor/ModuleName/
├── Api/                    # Service contract interfaces
│   └── Data/               # Data object interfaces
├── Block/                  # View models and blocks
├── Console/                # CLI commands
├── Controller/             # HTTP controllers
├── Cron/                   # Scheduled jobs
├── Model/                  # Business logic implementations
│   └── ResourceModel/      # Database layer
├── Observer/               # Event observers
├── Plugin/                 # Interceptors
├── Setup/                  # Schema + data patches
├── Test/                   # Unit + integration tests
├── Ui/                     # UI components
├── etc/                    # Configuration
│   ├── adminhtml/          # Admin-specific config
│   ├── frontend/           # Frontend-specific config
│   ├── db_schema.xml       # Declarative schema
│   ├── di.xml              # DI configuration
│   ├── events.xml          # Event observers
│   └── module.xml          # Module declaration
├── view/                   # Templates, layouts, JS, CSS
├── composer.json
└── registration.php
Enter fullscreen mode Exit fullscreen mode

This structure is predictable for any Magento developer joining your project.

Staying upgrade-safe

  • Prefer preference in di.xml as a last resort; use plugins when possible
  • Don't override core templates unless absolutely necessary
  • Test against the next minor Magento version in CI before it's released
  • Follow Magento's deprecation notices in each release's changelogs

Originally published on magevanta.com

Top comments (0)