Een slecht gebouwde Magento module brak je shop bij de volgende update. Een goed gebouwde module is onzichtbaar. Het verschil zit in vijf fundamentele keuzes die de meeste developers te laat maken.
Custom Magento 2 module bouwen — best practices 2026
Een slecht gebouwde Magento module is geen technisch probleem op dag één. Het wordt een probleem bij de eerste grote update, de eerste performance-issue of de eerste developer die de code leest en niet begrijpt wat er gebeurt.
Wij bouwen en beoordelen regelmatig custom modules. De kwaliteitsverschillen zijn groot. Dit zijn de patronen die werken en de aanpakken die op termijn problemen geven.
Wat je leert in dit artikel
- De correcte module-structuur in 2026
- Dependency Injection: de basis die alles bepaalt
- Plugins versus observers: wanneer gebruik je wat
- Service contracts als API-laag
- Veelgemaakte fouten die je update-pad blokkeren
Module-structuur in 2026
Een correcte Magento 2 module-structuur:
app/code/Vendor/ModuleName/
├── Api/
│ ├── Data/
│ │ └── ProductExtensionInterface.php
│ └── ProductServiceInterface.php
├── Block/
│ └── Product/
│ └── Custom.php
├── Console/
│ └── Command/
│ └── SyncProducts.php
├── Controller/
│ └── Index/
│ └── Index.php
├── Cron/
│ └── SyncJob.php
├── etc/
│ ├── acl.xml
│ ├── config.xml
│ ├── crontab.xml
│ ├── di.xml
│ ├── events.xml
│ ├── frontend/
│ │ └── routes.xml
│ ├── module.xml
│ └── webapi.xml
├── Model/
│ ├── Data/
│ │ └── ProductExtension.php
│ └── ProductService.php
├── Observer/
│ └── ProductSaveAfter.php
├── Plugin/
│ └── Product/
│ └── PricePlugin.php
├── Setup/
│ ├── InstallData.php
│ └── InstallSchema.php
├── view/
│ └── frontend/
│ ├── layout/
│ │ └── catalog_product_view.xml
│ └── templates/
│ └── product/
│ └── custom.phtml
├── composer.json
└── registration.php
Dit is niet de minimale structuur — dit is de structuur die je gebruikt voor een module die schaalbaar en onderhoudbaar is.
registration.php en module.xml
<?php
// registration.php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Vendor_ModuleName',
__DIR__
);
<!-- 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="Vendor_ModuleName">
<sequence>
<module name="Magento_Catalog"/>
<module name="Magento_Customer"/>
</sequence>
</module>
</config>
De sequence bepaalt de volgorde van module-loading. Declareer hier alle modules waarvan jij afhankelijk bent. Vergeet je een afhankelijkheid, dan kan je module laden voordat de afhankelijke module geladen is.
Dependency Injection
DI is het fundament van Magento 2-architectuur. Het verschil tussen een goede module en een slechte module zit vaak hier.
Constructor injection
<?php
// Correct: alle dependencies via constructor injecteren
namespace Vendor\ModuleName\Model;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Psr\Log\LoggerInterface;
class ProductService implements \Vendor\ModuleName\Api\ProductServiceInterface
{
public function __construct(
private readonly ProductRepositoryInterface $productRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
private readonly LoggerInterface $logger,
) {}
public function getActiveProducts(): array
{
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('status', 1)
->create();
try {
return $this->productRepository->getList($searchCriteria)->getItems();
} catch (\Exception $e) {
$this->logger->error('ProductService error: ' . $e->getMessage());
return [];
}
}
}
Gebruik PHP 8.1+ constructor property promotion (private readonly). Dit is korter, leesbaarder en correct.
Verbinding via di.xml
<!-- etc/di.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<!-- Interface koppelen aan concrete implementatie -->
<preference for="Vendor\ModuleName\Api\ProductServiceInterface"
type="Vendor\ModuleName\Model\ProductService"/>
<!-- Virtual type voor hergebruik van configuratie -->
<virtualType name="Vendor\ModuleName\Model\Logger"
type="Magento\Framework\Logger\Monolog">
<arguments>
<argument name="name" xsi:type="string">vendor_module</argument>
</arguments>
</virtualType>
</config>
ObjectManager direct aanroepen in je code. Dat is het anti-pattern dat DI omzeilt en je module ontestvoud maakt.
// NOOIT DOEN
$objectManager = \Magento\Framework\App\ObjectManager::getInstance();
$product = $objectManager->create(\Magento\Catalog\Model\Product::class);
// Correct: via constructor injection
public function __construct(
private readonly \Magento\Catalog\Model\ProductFactory $productFactory,
) {}
Plugins versus observers
Dit is de meest gestelde vraag bij module-ontwikkeling. Het antwoord hangt af van wat je wil doen.
Plugins
Plugins (interceptors) werken op publieke methoden van klassen. Je hebt drie opties: before, after en around.
<?php
// Plugin om prijs aan te passen vóór weergave
namespace Vendor\ModuleName\Plugin\Product;
use Magento\Catalog\Model\Product;
class PricePlugin
{
// Before plugin: pas argumenten aan voordat de methode wordt uitgevoerd
public function beforeGetPrice(Product $subject): array
{
// Geen aanpassing van de prijs hier, maar je kunt argumenten wijzigen
return [];
}
// After plugin: pas de returnwaarde aan
public function afterGetPrice(Product $subject, float $result): float
{
// Voeg 10% toe voor B2B klanten (voorbeeld)
if ($this->customerSession->isLoggedIn() && $this->isB2BCustomer()) {
return $result * 1.10;
}
return $result;
}
// Around plugin: volledige controle over de methode-executie
public function aroundGetPrice(Product $subject, callable $proceed): float
{
// Around plugins zijn krachtig maar gevaarlijk:
// ze voorkomen dat andere plugins na jou de methode kunnen aanpassen
// Gebruik dit alleen als before/after niet volstaan
$result = $proceed();
return $result;
}
}
<!-- etc/di.xml: plugin registreren -->
<type name="Magento\Catalog\Model\Product">
<plugin name="vendor_module_price_plugin"
type="Vendor\ModuleName\Plugin\Product\PricePlugin"
sortOrder="10"/>
</type>
$proceed() niet aanroepen, blokkeren alle andere plugins in de keten. Dit is de meest voorkomende oorzaak van onverklaarbaar gedrag na module-installatie.
Observers
Observers reageren op events. Ze zijn geschikt voor side-effects: logging, het versturen van notificaties, het bijwerken van externe systemen.
<?php
// Observer voor product-opslag
namespace Vendor\ModuleName\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
class ProductSaveAfter implements ObserverInterface
{
public function __construct(
private readonly \Vendor\ModuleName\Model\ExternalSync $externalSync,
private readonly \Psr\Log\LoggerInterface $logger,
) {}
public function execute(Observer $observer): void
{
$product = $observer->getEvent()->getProduct();
try {
$this->externalSync->syncProduct($product);
} catch (\Exception $e) {
// Observers mogen nooit een exception gooien die de originele operatie blokkeert
$this->logger->error(
'Sync failed for product ' . $product->getId() . ': ' . $e->getMessage()
);
}
}
}
<!-- etc/events.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="catalog_product_save_after">
<observer name="vendor_module_product_save_after"
instance="Vendor\ModuleName\Observer\ProductSaveAfter"/>
</event>
</config>
Kernregel voor observers: gooi nooit een uncaught exception. Een observer die crasht, blokkeert de originele operatie. Een product dat niet opgeslagen kan worden omdat jouw sync-observer faalt is een ernstig probleem.
Keuzetabel
| Situatie | Gebruik |
|---|---|
| Returnwaarde van methode aanpassen | Plugin (after) |
| Argumenten aanpassen voor methode-aanroep | Plugin (before) |
| Reactie op een event (logging, sync, notificatie) | Observer |
| Methode volledig vervangen | Plugin (around) — met voorzichtigheid |
| Nieuwe methode toevoegen aan klasse | Extensie via preference (vermijd dit) |
Service contracts
Service contracts zijn de publieke API van je module. Ze definiëren wat andere modules mogen gebruiken van jouw module.
<?php
// Api/ProductServiceInterface.php — de contract
namespace Vendor\ModuleName\Api;
interface ProductServiceInterface
{
/**
* Geeft actieve producten terug op basis van categorie
*
* @param int $categoryId
* @return \Magento\Catalog\Api\Data\ProductInterface[]
* @throws \Magento\Framework\Exception\NoSuchEntityException
*/
public function getProductsByCategory(int $categoryId): array;
/**
* Synchroniseert product met extern systeem
*
* @param int $productId
* @return bool
*/
public function syncProduct(int $productId): bool;
}
Definieer service contracts voor alle functionaliteit die andere modules mogen aanroepen. Gebruik de interface, nooit de concrete implementatie. Dit geeft andere modules de vrijheid om je implementatie te vervangen via di.xml.
Testen
Een module zonder tests is een module die je bang maakt aan te passen.
<?php
// Test/Unit/Model/ProductServiceTest.php
namespace Vendor\ModuleName\Test\Unit\Model;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use PHPUnit\Framework\TestCase;
use Vendor\ModuleName\Model\ProductService;
class ProductServiceTest extends TestCase
{
private ProductService $service;
private ProductRepositoryInterface $productRepository;
protected function setUp(): void
{
$this->productRepository = $this->createMock(ProductRepositoryInterface::class);
$searchCriteriaBuilder = $this->createMock(SearchCriteriaBuilder::class);
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
$this->service = new ProductService(
$this->productRepository,
$searchCriteriaBuilder,
$logger,
);
}
public function testGetActiveProductsReturnsEmptyArrayOnException(): void
{
$this->productRepository
->method('getList')
->willThrowException(new \Exception('DB error'));
$result = $this->service->getActiveProducts();
$this->assertIsArray($result);
$this->assertEmpty($result);
}
}
# Tests uitvoeren
./vendor/bin/phpunit app/code/Vendor/ModuleName/Test/Unit/
# Met coverage
./vendor/bin/phpunit --coverage-html ./coverage app/code/Vendor/ModuleName/Test/
Veelgemaakte fouten
1. Preference gebruiken om core klassen te overschrijvenEen preference vervangt een klasse volledig. Als twee modules hetzelfde core model overschrijven, wint de laatste. Gebruik plugins waar mogelijk.
2. Sessie of request in een model injecterenModels zijn domeinlogica. Sessie en request zijn presentatielaag-dependencies. Een model dat de sessie aanroept is ontestvoud en architectureel incorrect.
3. Events aanroepen in een loopElke $this->eventManager->dispatch() aanroep heeft overhead. Dispatch events niet in een loop over 1.000 producten.
Templates (phtml) mogen geen database-queries doen. Data moet via blocks of view models worden aangeleverd. Dit is de meest voorkomende performance-oorzaak in zelfgebouwde modules.
<?php
// Correct: view model aanmaken voor template-data
namespace Vendor\ModuleName\ViewModel;
use Magento\Framework\View\Element\Block\ArgumentInterface;
class ProductData implements ArgumentInterface
{
public function __construct(
private readonly \Vendor\ModuleName\Api\ProductServiceInterface $productService,
) {}
public function getActiveProducts(): array
{
return $this->productService->getActiveProducts();
}
}
Elke schema- of datawijziging vereist een Setup script met versie-increment. Directe database-wijzigingen zonder Setup script zijn niet reproduceerbaar op andere omgevingen.
Conclusie
Een goede Magento 2 module is onzichtbaar. Hij werkt, overleeft updates en laat andere developers hun eigen werk doen zonder last van jouw code.
De investering in de juiste structuur — DI, service contracts, plugins in plaats van preferences, observers zonder exceptions — betaalt zich terug bij elke update en elke toevoeging aan de codebase.
Wij beoordelen en bouwen custom Magento modules voor complexe e-commerce vraagstukken. Bekijk wat wij doen of neem direct contact op. De officiële Adobe module development guide is de primaire referentie voor best practices.
Meer over het up-to-date houden van je Magento 2 installatie? Lees Magento 2 updatestrategie.

Geschreven door Ruthger Idema
15+ jaar ervaring in e-commerce development. Gespecialiseerd in Magento, Shopify en Laravel maatwerk.
Meer over ons team →