1. The Store That Could Not Take a Security Patch
In late 2023 I was asked to quote an upgrade from Magento 2.4.3-p2 to 2.4.6 for a industrial supplies distributor. Two versions behind on a platform with an active exploit history, so not really optional. I expected a fortnight.
The quote came out at eleven weeks and they did not proceed for another year.
The reason was sitting in a single file. A previous developer had needed to change how quote item prices were calculated for trade customers, and had done it with a preference — a class override — on Magento\Quote\Model\Quote\Item. Not a plugin on the one method that mattered. A full subclass of a core model, copied from the 2.4.3 source, with about forty lines changed and eleven hundred lines identical to core.
Between 2.4.3 and 2.4.6, Magento changed that class. Constructor signature, two method bodies, a new dependency. The override still loaded, still ran, and still produced prices — using the 2.4.3 logic against a 2.4.6 database, with a constructor that no longer matched what the object manager was passing. It did not fatal. It quietly calculated tax wrong on multi-shipment orders.
There were nine more preferences like it. Four were unnecessary and could have been plugins. Three were unnecessary and could have been nothing at all, because the behaviour they added had since become a core configuration option. Two were genuinely hard and needed rewriting properly. Auditing, rewriting and regression-testing them was nine of the eleven weeks, and the upgrade itself was two.
I have written bad preferences myself. Early on, on a smaller store, I overrode a shipping carrier model because I could not work out which plugin point I wanted and the deadline was Friday. That store is still on 2.3, partly because of me, and the merchant does not know that is why.
This article is about the fundamentals of authoring custom magento modules that do not do that: the file structure and what each piece is for, dependency injection and how the object manager actually resolves your class, the real difference between plugins, observers and preferences with a rule for choosing, declarative schema and data patches, and a catalogue of the anti-patterns that turn a working store into one nobody can upgrade.
2. What a Module Is on Disk
A Magento module is a directory with two mandatory files and a lot of optional convention. Everything else — the controllers, the models, the templates — is discovered by naming and by XML.
app/code/Vendor/TradePricing/
├── registration.php # mandatory: tells Magento this exists
├── composer.json # required in practice, for deployment
├── etc/
│ ├── module.xml # mandatory: name and load sequence
│ ├── di.xml # dependency injection, all areas
│ ├── events.xml # observers, all areas
│ ├── db_schema.xml # table definitions, declarative
│ ├── db_schema_whitelist.json # generated, must be committed
│ ├── acl.xml # admin permission tree
│ ├── webapi.xml # REST endpoint routing
│ ├── frontend/
│ │ ├── di.xml # storefront-only DI
│ │ ├── events.xml # storefront-only observers
│ │ └── routes.xml # storefront controller routing
│ └── adminhtml/
│ ├── di.xml
│ ├── routes.xml
│ ├── menu.xml
│ └── system.xml # Stores > Configuration fields
├── Api/
│ ├── TradePriceRepositoryInterface.php
│ └── Data/TradePriceInterface.php
├── Model/
├── Plugin/
├── Observer/
├── Setup/Patch/Data/
├── ViewModel/
├── view/frontend/
│ ├── layout/
│ └── templates/
└── Test/Unit/
The area directories matter more than they look. A di.xml in etc/ applies everywhere including the CLI and cron; one in etc/frontend/ applies only to storefront requests. Putting a storefront plugin in the global di.xml means it also runs during every bin/magento command and every indexer pass, which is how a harmless price decorator becomes a reindex that takes four hours.
I put nothing in global di.xml unless it genuinely has to be there. It is the single cheapest performance discipline in module development and almost nobody follows it.
<?php
// registration.php — identical in every module except the name.
declare(strict_types=1);
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Vendor_TradePricing',
__DIR__
);
<?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_TradePricing">
<!-- sequence controls LOAD ORDER, not dependency. It decides
whose di.xml, layout XML and events.xml win when two
modules configure the same thing. It does NOT make
Magento install these first if they are absent — that is
composer.json's job, and you need both. -->
<sequence>
<module name="Magento_Quote"/>
<module name="Magento_Catalog"/>
<module name="Magento_CustomerGraphQl"/>
</sequence>
</module>
</config>
The setup_version attribute that older tutorials show is gone. Since 2.3, schema is declarative and data changes are patches, and a module with a version number in module.xml is a sign you are reading documentation written for a platform that no longer exists.
3. Dependency Injection, and the Object Manager You Must Never Call
Magento constructs your objects for you. You declare what you need in the constructor, typed against an interface, and the framework resolves it.
<?php
declare(strict_types=1);
namespace Vendor\TradePricing\Model;
use Magento\Customer\Api\GroupRepositoryInterface;
use Magento\Framework\Pricing\PriceCurrencyInterface;
use Psr\Log\LoggerInterface;
class TradePriceCalculator
{
// Constructor promotion, readonly, interfaces not concretes.
// Everything about this signature is a decision you are making
// about how testable and how upgrade-safe this class is.
public function __construct(
private readonly GroupRepositoryInterface $groupRepository,
private readonly PriceCurrencyInterface $priceCurrency,
private readonly LoggerInterface $logger,
private readonly array $rules = []
) {
}
}
Three rules I hold to without exception.
Type against interfaces. ProductRepositoryInterface, not ProductRepository. The interface is the contract Magento promises not to break within a major version; the implementation is not. Half the upgrade pain I have unpicked came from code typed against concrete classes that moved.
Never call the object manager directly. ObjectManager::getInstance()->create() works, appears in old Stack Overflow answers, and hides your dependencies from everything — from the compiler, from your tests, from the next developer. The only legitimate uses are inside factories and inside code that runs before DI is available. If you find it in a model, it is a bug.
Inject factories for things with state. Anything that represents one record — a product, an order, a custom entity — must not be injected as a singleton, because you would be sharing one instance across the request. Inject the generated Factory and call create().
<?php
// Non-injectable: an entity carries state, so a shared instance is
// a bug waiting for a second call in the same request.
public function __construct(
private readonly \Vendor\TradePricing\Model\TradePriceFactory $priceFactory
) {
}
public function makeOne(int $customerGroupId, float $value): TradePrice
{
// The Factory class does not exist on disk. Magento generates it
// into generated/code the first time it is needed, or at
// setup:di:compile time. Referencing it is enough.
$price = $this->priceFactory->create();
$price->setCustomerGroupId($customerGroupId);
$price->setValue($value);
return $price;
}
Proxies are the other generated class worth understanding. A \Proxy suffix gives you a lazy stand-in: the real object is not constructed until you call a method on it. Use them for expensive dependencies that are needed on only some code paths, and particularly in constructors of classes that run on every request.
<!-- etc/di.xml — the four things this file does, in order of how
often they are the right answer. -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<!-- 1. Bind your own interface to your own implementation. This
is what di.xml is really for. -->
<preference for="Vendor\TradePricing\Api\TradePriceRepositoryInterface"
type="Vendor\TradePricing\Model\TradePriceRepository"/>
<!-- 2. Register a plugin. -->
<type name="Magento\Catalog\Pricing\Price\FinalPrice">
<plugin name="vendor_tradepricing_final_price"
type="Vendor\TradePricing\Plugin\ApplyTradePrice"
sortOrder="20"/>
</type>
<!-- 3. Pass constructor arguments without touching PHP. The
$rules array in TradePriceCalculator is populated here,
which means another module can extend the list by adding
an item rather than by subclassing anything. -->
<type name="Vendor\TradePricing\Model\TradePriceCalculator">
<arguments>
<argument name="rules" xsi:type="array">
<item name="volume" xsi:type="object">
Vendor\TradePricing\Model\Rule\VolumeBreak</item>
<item name="contract" xsi:type="object">
Vendor\TradePricing\Model\Rule\ContractPrice</item>
</argument>
<argument name="logger" xsi:type="object">
Vendor\TradePricing\Logger\TradePriceLogger</argument>
</arguments>
</type>
<!-- 4. Virtual types: a configured variant of an existing class,
with no PHP file at all. This one is a logger that writes
to its own file instead of system.log. -->
<virtualType name="Vendor\TradePricing\Logger\TradePriceLogger"
type="Magento\Framework\Logger\Monolog">
<arguments>
<argument name="handlers" xsi:type="array">
<item name="debug" xsi:type="object">
Vendor\TradePricing\Logger\Handler\Debug</item>
</argument>
</arguments>
</virtualType>
</config>
Virtual types are underused and they solve a real problem: you want the same class configured two different ways in two different places. Without them you write a subclass whose only content is a different constructor default, which is a file that exists purely to hold configuration.
4. Preferences: The Sharp Tool
A preference tells the object manager "wherever anyone asks for class A, give them class B instead". It is global, it is total, and only one can win.
That last point is the one that ends most arguments. If two modules declare a preference for the same core class, the one that loads later wins and the other silently does nothing. There is no chaining, no warning, and no runtime error. A store with two extensions both overriding Magento\Sales\Model\Order has one extension that half works, and nobody finds out until an order behaves oddly.
When a preference is correct:
Binding your own interface to your own implementation. This is the intended use and it accounts for the overwhelming majority of legitimate preferences in a well-written module.
Replacing a core interface implementation wholesale, deliberately, where you genuinely intend to provide the entire behaviour — a different tax calculator, a different shipping rate provider — and you have accepted that you now own that class's compatibility with every future version.
When it is wrong: essentially everywhere else, and specifically whenever you find yourself copying a core class and changing part of it. That is the pattern that cost the distributor eleven weeks. If you only need to change what one method returns, you need a plugin.
<?php
// The anti-pattern, written out so it is recognisable.
// 1,100 lines copied from core, 40 changed, and now this class is
// frozen at the version it was copied from.
namespace Vendor\TradePricing\Model\Quote;
class Item extends \Magento\Quote\Model\Quote\Item
{
public function setPrice($value)
{
if ($this->getProduct()->getData('is_trade_line')) {
$value = $this->applyTradeAdjustment($value);
}
return parent::setPrice($value);
}
// ... plus 1,100 lines that are identical to core and will
// diverge from it on the next upgrade without anyone noticing.
}
The version of that which I would sign off on is nine lines of plugin, below.
5. Plugins: Intercepting Without Owning
A plugin wraps a public method of an existing class. Magento generates an interceptor subclass at compile time, and the object manager hands out the interceptor instead of the original. Multiple plugins on the same method chain rather than replacing each other, which is the entire reason to prefer them.
Three types.
<?php
declare(strict_types=1);
namespace Vendor\TradePricing\Plugin;
use Magento\Quote\Model\Quote\Item;
use Vendor\TradePricing\Model\TradePriceCalculator;
class ApplyTradePrice
{
public function __construct(
private readonly TradePriceCalculator $calculator
) {
}
/**
* BEFORE: modify the arguments on the way in.
* Must return an array of arguments, or null to leave them alone.
* Returning a bare value instead of an array is the most common
* plugin bug and produces a confusing type error deep in core.
*/
public function beforeSetPrice(Item $subject, $value): array
{
if (!$subject->getProduct()?->getData('is_trade_line')) {
return [$value];
}
return [$this->calculator->adjust((float) $value, $subject)];
}
/**
* AFTER: modify the return value on the way out.
* $result is whatever the method returned. Extra parameters after
* it are the original arguments, which you need surprisingly often.
*/
public function afterGetName(Item $subject, string $result): string
{
return $subject->getProduct()?->getData('is_trade_line')
? $result . ' (trade)'
: $result;
}
/**
* AROUND: wraps the call entirely. $proceed IS the rest of the
* chain plus the original method. Failing to call it silently
* disables every plugin registered after yours, plus the method.
*/
public function aroundCalcRowTotal(
Item $subject,
callable $proceed
) {
if (!$subject->getProduct()?->getData('is_trade_line')) {
return $proceed();
}
$this->calculator->prime($subject);
$result = $proceed();
$this->calculator->reconcile($subject);
return $result;
}
}
My position on around plugins: avoid them. They are the most expensive of the three, because Magento has to construct a closure for the remainder of the chain on every call whether or not you use it, and they are the easiest to get catastrophically wrong. An around plugin that returns early without calling $proceed() disables core behaviour and every other extension's plugin on that method, and produces no error at all.
I have found exactly one class of legitimate around plugin in production work: genuinely needing to run code both before and after with shared local state, or needing to conditionally skip the original call. Everything else is a before and an after.
What plugins cannot touch
The limitations are not documented as prominently as they should be and they cause a lot of wasted afternoons.
Not final classes or final methods. Not static methods. Not constructors. Not private or protected methods. Not objects created with new rather than by the object manager. Not classes instantiated before the interceptor system is bootstrapped, which includes a handful of framework internals.
And — the one that catches people every time — a plugin on a method that the class calls internally will not fire, because $this->someMethod() inside the original class bypasses the interceptor entirely. You are wrapping the object, not the method.
Sort order and the chain
When several plugins target one method, sortOrder decides the sequence, and the direction differs by type.
| Type | Runs in | Can change | Cost |
|---|---|---|---|
before | Ascending sortOrder | Arguments | Low |
around (entry) | Ascending sortOrder | Everything | High |
around (exit) | Descending sortOrder | Everything | High |
after | Descending sortOrder | Return value | Low |
The reversal on after is real and it surprises people. If you need your after plugin to run last, you give it the lowest sort order, not the highest. I have debugged a pricing conflict between two extensions for half a day before remembering this.
Use bin/magento dev:di:info to see the actual resolved chain for a class rather than reasoning about it from XML. On a store with forty extensions the XML is spread across forty files and merged in an order nobody can hold in their head.
6. Observers and the Event System
An observer listens for a named event that some code explicitly dispatched. It runs after the fact, receives whatever the dispatcher chose to put in the event data, and cannot change the return value of anything.
<!-- etc/frontend/events.xml — frontend area only, so this does not
fire during reindexing, cron, or admin order creation. Whether
that is what you want is a decision, not a default. -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="checkout_cart_add_product_complete">
<observer name="vendor_tradepricing_log_trade_add"
instance="Vendor\TradePricing\Observer\LogTradeAdd"/>
</event>
</config>
<?php
declare(strict_types=1);
namespace Vendor\TradePricing\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Psr\Log\LoggerInterface;
class LogTradeAdd implements ObserverInterface
{
public function __construct(
private readonly LoggerInterface $logger
) {
}
public function execute(Observer $observer): void
{
// An observer that throws takes down whatever dispatched the
// event. On a checkout event that means a customer sees a
// 500 because your analytics call timed out. Catch broadly,
// log, and let the request continue.
try {
$product = $observer->getEvent()->getProduct();
if (!$product?->getData('is_trade_line')) {
return;
}
$this->logger->info('trade line added', ['sku' => $product->getSku()]);
} catch (\Throwable $e) {
$this->logger->error('trade add observer failed', ['exception' => $e]);
}
}
}
That try/catch is not defensive padding. Magento dispatches events synchronously inside the request, and an uncaught exception in an observer on sales_order_place_after will roll back an order that has already been charged. I have seen it happen, on a store where a shipping-label observer threw because a courier API was down, and the customer's card was debited for an order that did not exist.
7. Choosing Between the Three
The decision is usually straightforward once you ask the questions in the right order.
Do you need to change what a method does, or react to something happening? React means observer. Change means plugin or preference.
Is there an event already dispatched at exactly the point you need? If yes, use it. Events are the most stable extension point Magento has — they are part of the documented surface, they rarely move between versions, and multiple listeners coexist without conflict.
Is the method public, non-final, on an object the object manager creates? If yes, plugin. Before to change inputs, after to change outputs.
Are you replacing the entire behaviour of a class you own the contract for? Then a preference is fine.
Anything else? Step back, because you are probably about to write something that will hurt. The honest answer at this point is sometimes "the extension point does not exist", and the right response is either a pull request to Magento, or a design that works around the gap rather than through it.
| Need | Use | Why not the others |
|---|---|---|
| Add a field to an API response | Extension attributes | A plugin on the repository works but bypasses the contract |
| Change a calculated price | after plugin | Preference freezes you at one version |
| Send an email when an order ships | Observer | No behaviour change needed |
| Validate before save | before plugin | Observer cannot stop the save cleanly |
| Skip core logic conditionally | around plugin | The one case it earns its cost |
| Supply your own interface implementation | Preference | Correct by design |
8. Declarative Schema, and Why InstallSchema Died
Before 2.3, database changes were sequential PHP scripts: InstallSchema, then a chain of UpgradeSchema blocks keyed on version number. To know what a table looked like you replayed the history. Rollback was impossible and a failed upgrade left you halfway.
Declarative schema replaced it with a desired-state document. You describe the table you want; Magento diffs it against what exists and generates the DDL.
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="vendor_trade_price" resource="default" engine="innodb"
comment="Contract prices per customer group and SKU">
<column xsi:type="int" name="entity_id" unsigned="true"
nullable="false" identity="true"/>
<column xsi:type="int" name="customer_group_id" unsigned="true"
nullable="false"/>
<column xsi:type="int" name="product_id" unsigned="true"
nullable="false"/>
<column xsi:type="decimal" name="price" scale="4" precision="20"
nullable="false" default="0"/>
<column xsi:type="timestamp" name="updated_at" nullable="false"
default="CURRENT_TIMESTAMP" on_update="true"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="entity_id"/>
</constraint>
<!-- Cascade delete: when a product goes, its contract prices
go with it. Without this you accumulate orphan rows that
break the admin grid two years later. -->
<constraint xsi:type="foreign" referenceId="VENDOR_TRADE_PRICE_PRODUCT_ID"
table="vendor_trade_price" column="product_id"
referenceTable="catalog_product_entity"
referenceColumn="entity_id" onDelete="CASCADE"/>
<constraint xsi:type="unique" referenceId="VENDOR_TRADE_PRICE_GROUP_PRODUCT">
<column name="customer_group_id"/>
<column name="product_id"/>
</constraint>
<index referenceId="VENDOR_TRADE_PRICE_UPDATED_AT" indexType="btree">
<column name="updated_at"/>
</index>
</table>
</schema>
The whitelist file is the part people forget. Magento will not drop a column or index it does not know your module created, which is a safety feature — it stops your module deleting someone else's additions to a shared table. The whitelist is generated, not hand-written, and it must be committed.
# Regenerate after every db_schema.xml change. Forgetting this means
# your removals are silently ignored on other environments, so the
# schema differs between staging and production and nobody knows.
bin/magento setup:db-declaration:generate-whitelist \
--module-name=Vendor_TradePricing
# Dry run against the current database. Prints the DDL it would run
# without running it — the only safe way to review a schema change
# before it touches production.
bin/magento setup:db:status
bin/magento setup:upgrade --dry-run
Data patches
Schema is declarative; data is not. Inserting a config value, creating an attribute, backfilling a column — those are patches, applied once, recorded in the patch_list table by class name.
<?php
declare(strict_types=1);
namespace Vendor\TradePricing\Setup\Patch\Data;
use Magento\Eav\Setup\EavSetupFactory;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\Framework\Setup\Patch\PatchRevertableInterface;
use Magento\Catalog\Model\Product;
class AddTradeLineAttribute implements DataPatchInterface, PatchRevertableInterface
{
public function __construct(
private readonly ModuleDataSetupInterface $moduleDataSetup,
private readonly EavSetupFactory $eavSetupFactory
) {
}
// Patches this one must have run first. Use class names, and
// include patches from other modules where the ordering matters.
public static function getDependencies(): array
{
return [];
}
// If this patch was previously deployed under a different class
// name, list it here so it is not applied twice after a rename.
public function getAliases(): array
{
return [];
}
public function apply(): self
{
$this->moduleDataSetup->startSetup();
$this->eavSetupFactory->create(['setup' => $this->moduleDataSetup])
->addAttribute(Product::ENTITY, 'is_trade_line', [
'type' => 'int',
'label' => 'Trade Line',
'input' => 'boolean',
'source' => \Magento\Eav\Model\Entity\Attribute\Source\Boolean::class,
'global' => \Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface::SCOPE_WEBSITE,
'required' => false,
'default' => '0',
'group' => 'Trade Pricing',
'used_in_product_listing' => true,
]);
$this->moduleDataSetup->endSetup();
return $this;
}
// Revert is optional and almost nobody writes it. Write it. It
// is what makes a failed release recoverable without a restore.
public function revert(): void
{
$this->moduleDataSetup->startSetup();
$this->eavSetupFactory->create(['setup' => $this->moduleDataSetup])
->removeAttribute(Product::ENTITY, 'is_trade_line');
$this->moduleDataSetup->endSetup();
}
}
One hard rule about patches: they are recorded by class name, so never edit a patch that has been deployed. It will not re-run. Write a second patch. I have watched a developer fix a typo in an applied patch, test it on a fresh install where it ran correctly, and deploy to production where nothing happened at all.
A patch that backfills a large table needs batching and a timeout that assumes production data volumes, not development ones. A patch that iterates 400,000 products with a repository call each will run for hours and block your deployment, which is precisely the wrong place to discover it. That intersects with release strategy generally, and the approaches in zero-downtime deployment for ecommerce apply directly.
9. Service Contracts and the API Layer
Magento's service contracts are the interfaces in Api/ and Api/Data/. They are the boundary the platform promises to keep stable, and building your module against them — and exposing your own — is what makes it survivable.
The pattern is a data interface describing the entity, a repository interface describing the operations, and implementations that are bound in di.xml. The repository is what other modules and the REST API talk to; the resource model and collection stay internal.
<?php
declare(strict_types=1);
namespace Vendor\TradePricing\Api;
use Magento\Framework\Api\SearchCriteriaInterface;
use Vendor\TradePricing\Api\Data\TradePriceInterface;
interface TradePriceRepositoryInterface
{
/**
* The docblock is not documentation here — Magento parses it to
* generate the REST response schema and to type-coerce input.
* A wrong @return annotation produces a working PHP method and a
* broken API endpoint, with no error anywhere.
*
* @param int $id
* @return \Vendor\TradePricing\Api\Data\TradePriceInterface
* @throws \Magento\Framework\Exception\NoSuchEntityException
*/
public function getById(int $id): TradePriceInterface;
/**
* @param \Vendor\TradePricing\Api\Data\TradePriceInterface $tradePrice
* @return \Vendor\TradePricing\Api\Data\TradePriceInterface
*/
public function save(TradePriceInterface $tradePrice): TradePriceInterface;
/**
* @param \Magento\Framework\Api\SearchCriteriaInterface $criteria
* @return \Vendor\TradePricing\Api\Data\TradePriceSearchResultsInterface
*/
public function getList(SearchCriteriaInterface $criteria);
}
<!-- etc/webapi.xml — one file turns the repository into REST. The
ACL resource here is enforced by the framework; do not implement
your own permission check in the controller. -->
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_WebApi:etc/webapi.xsd">
<route url="/V1/trade-prices/:id" method="GET">
<service class="Vendor\TradePricing\Api\TradePriceRepositoryInterface"
method="getById"/>
<resources>
<resource ref="Vendor_TradePricing::trade_price_view"/>
</resources>
</route>
</routes>
Extension attributes deserve a mention because they are the correct answer to "add a field to a core entity's API output" and almost nobody reaches for them. You declare the attribute in extension_attributes.xml, populate it in an after plugin on the relevant repository, and it appears in the REST response and in GraphQL without you having touched a core class or a core template.
10. Backward Compatibility Is a Promise You Make Too
Magento annotates its stable surface with @api. Anything carrying that tag will not break within a major version; anything without it can move in a patch release. Reading that annotation before you type against a class is the cheapest upgrade insurance available, and it takes ten seconds.
The part people miss is that the same obligation runs downhill. Once a second module — yours or someone else's — depends on your repository interface, you have made the same promise, and changing a method signature becomes a breaking change with a version number attached.
What counts as breaking, in Magento's own terms: removing a public or protected method, adding a required constructor argument, changing a parameter or return type, or renaming a class. What does not: adding a method to a class (though adding one to an interface does break implementers), adding an optional constructor argument at the end, and anything private.
That constructor rule is the one that bites in practice. If you need a new dependency in a class other modules extend or plugin, add it as the last argument with a default of null and resolve it lazily inside, rather than inserting it in the middle of the signature. It is ugly. Magento's own core does it constantly, for exactly this reason.
<?php
// Adding a dependency without breaking anyone who already extends
// this class. New argument last, nullable, resolved on first use.
public function __construct(
private readonly GroupRepositoryInterface $groupRepository,
private readonly PriceCurrencyInterface $priceCurrency,
private ?ContractResolverInterface $contractResolver = null
) {
// ObjectManager here is the one legitimate exception to the rule
// against calling it: preserving a constructor signature for
// backward compatibility. Magento core uses this pattern widely.
$this->contractResolver = $contractResolver
?: \Magento\Framework\App\ObjectManager::getInstance()
->get(ContractResolverInterface::class);
}
Version your module semantically and mean it. A module that goes from 1.4.2 to 1.4.3 while changing an interface is worse than no versioning at all, because someone will trust it. If you are shipping to more than one store, a short changelog file with the breaking changes called out is thirty minutes a release and saves an afternoon of someone else's confusion.
11. The Frontend Half, Briefly
Layout XML wires blocks into containers, and the single most useful discipline is to keep logic out of both blocks and templates.
A ViewModel is a plain class implementing ArgumentInterface, injected into a generic block through layout XML. It has your dependencies, it is unit testable without the view layer, and it can be reused on three templates without any inheritance.
<referenceContainer name="product.info.main">
<block class="Magento\Framework\View\Element\Template"
name="vendor.trade.price.badge"
template="Vendor_TradePricing::product/badge.phtml"
after="product.info.price">
<arguments>
<argument name="tradePrice" xsi:type="object">
Vendor\TradePricing\ViewModel\TradePriceBadge</argument>
</arguments>
</block>
</referenceContainer>
Two things a template must never do: run a database query, and echo unescaped output. The first turns a category listing into an N+1 problem that surfaces as a slow page nobody can explain; the second is a stored XSS vector. Magento's $escaper is available in every template and there is no excuse.
If the store is on a modern frontend, the ViewModel pattern matters more rather than less, because there is no jQuery layer to paper over a badly shaped block. That interaction is covered from the theme side in the piece on Hyvä versus Luma on real Magento stores.
12. Configuration, Scope and Permissions
Hard-coded values are the quiet upgrade hazard nobody lists as an anti-pattern because they never break anything. They just make the module unusable on a second store view, and by the time that matters the deadline has moved.
system.xml puts fields in Stores > Configuration, config.xml supplies the defaults, and acl.xml declares which admin role may see them. The scope attributes on each field decide whether a value can differ per website or per store view, and getting them wrong is a small mistake now and a migration later.
<!-- etc/adminhtml/system.xml -->
<section id="trade_pricing" translate="label" sortOrder="300"
showInDefault="1" showInWebsite="1" showInStore="0">
<label>Trade Pricing</label>
<tab>catalog</tab>
<resource>Vendor_TradePricing::config</resource>
<group id="general" translate="label" sortOrder="10"
showInDefault="1" showInWebsite="1" showInStore="0">
<label>General</label>
<field id="enabled" translate="label" type="select" sortOrder="10"
showInDefault="1" showInWebsite="1" showInStore="0">
<label>Enable trade pricing</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
</field>
<!-- Anything secret must be obscure=1 so it is stored
encrypted and masked in the admin. An API key stored as
a plain text field ends up in a database dump on a
laptop, which is how credentials leak. -->
<field id="feed_token" translate="label" type="obscure" sortOrder="20"
showInDefault="1" showInWebsite="0" showInStore="0">
<label>Contract feed token</label>
<backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>
</field>
</group>
</section>
Reading configuration deserves a small wrapper class rather than ScopeConfigInterface calls scattered through the module. One class with typed methods, one place where the path strings live, and a single point to change when someone renames a field.
<?php
declare(strict_types=1);
namespace Vendor\TradePricing\Model;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
class Config
{
private const XML_ENABLED = 'trade_pricing/general/enabled';
public function __construct(
private readonly ScopeConfigInterface $scopeConfig
) {
}
// Passing the store explicitly rather than relying on the current
// scope is what makes this work from cron and from the CLI, where
// there is no "current store" and the default scope silently wins.
public function isEnabled(?int $storeId = null): bool
{
return $this->scopeConfig->isSetFlag(
self::XML_ENABLED,
ScopeInterface::SCOPE_STORE,
$storeId
);
}
}
The scope argument in that method is not decoration. A cron job that reads configuration without a store ID gets the default scope value, which on a multi-website store is frequently not the value anyone intended. I have debugged a nightly export that ran with the wrong currency for eight months for exactly this reason.
13. Caching: Participating Rather Than Fighting It
A module that renders anything on the storefront is operating inside full page cache, and the two failure modes are opposite. Either you cache something customer-specific and show one customer another's prices, or you disable caching to be safe and the store gets slow.
Trade pricing is squarely in this territory, because the price shown depends on the logged-in customer's group. The correct answer is not to disable the cache. It is to declare that your block varies by customer group, so Varnish and Magento's own FPC key on it.
<?php
// A block whose output legitimately varies. getCacheKeyInfo is what
// the block cache keys on; the customer group must be in there or
// the first visitor's price is served to everyone.
public function getCacheKeyInfo(): array
{
return array_merge(parent::getCacheKeyInfo(), [
'TRADE_PRICE',
$this->customerSession->getCustomerGroupId(),
$this->storeManager->getStore()->getId(),
]);
}
// Cache tags are how invalidation works. Tagging with the product's
// identity means a price change purges exactly the pages that showed
// it, rather than the whole cache.
public function getIdentities(): array
{
return [\Magento\Catalog\Model\Product::CACHE_TAG . '_' . $this->getProductId()];
}
For anything genuinely per-customer that must appear on a cached page, the mechanism is customer section data: the page is cached with a placeholder, and a small request after load fills in the personal part. That is how the minicart works and it is the pattern to copy rather than to reinvent.
Custom cache types are worth declaring when your module computes something expensive. Declaring one in cache.xml gives it a row in the admin's cache management screen, which means support can flush your cache without flushing everything — a small courtesy that saves real time during an incident.
14. Cron, Queues, and Work That Does Not Fit in a Request
Anything that talks to a third party, processes a file, or touches more than a few hundred rows belongs outside the request cycle. Magento gives you two mechanisms and they are not interchangeable.
crontab.xml schedules a class to run on a cron expression, in a group. Putting your jobs in their own group is worth the four lines, because the default group is shared with indexers and a slow job of yours will delay them.
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
<group id="trade_pricing">
<job name="vendor_tradepricing_import"
instance="Vendor\TradePricing\Cron\ImportContracts"
method="execute">
<schedule>15 2 * * *</schedule>
</job>
</group>
</config>
Message queues are the other half. Magento ships a queue framework with a database backend by default and AMQP if you configure RabbitMQ, and it is the right tool when work arrives in response to an event rather than on a clock — a contract price changed, so reprice these forty products.
The rule I apply to both: the job must be idempotent and it must be safe to run twice concurrently. Cron overlap happens whenever a run takes longer than the interval, and Magento's default schedule generation will happily start a second copy. A lock, a status column, or an advisory lock in the database, and a log line when the lock is not acquired.
The second rule: a cron job that fails must be visible. Magento records failures in cron_schedule with a status and a message, and nobody looks at that table. Emit a metric or write to a channel someone reads, because the nightly import that has been silently failing for three weeks is a genre of incident, not an exception.
15. The Anti-Patterns That Freeze a Store
Every one of these I have found in a paid audit, most of them more than once.
Preferences on core classes. Already covered, and still the worst. Grep for <preference for="Magento\ across app/code; every hit is a question that needs an answer.
Core files copied into app/code/Magento. Magento's fallback means a module directory in app/code shadows the same module in vendor. Someone discovers this, copies a core module to change three lines, and now Composer updates that module and nothing changes. This is the most invisible of all the anti-patterns because composer.json looks correct.
Editing vendor/ directly. Works until the next composer install, which is the next deployment. I have seen this "fixed" by adding vendor/ to Git, which converts a temporary problem into a permanent one.
ObjectManager::getInstance() in application code. Hides dependencies, defeats compilation, makes testing impossible, and is usually a sign that someone did not want to change a constructor signature that another module depends on.
Raw SQL against core tables. A ResourceConnection query that joins catalog_product_entity_varchar directly will work, will be fast, and will break when the EAV structure changes or when a store view scope you did not consider produces two rows. Repositories and collections exist for this.
Business logic in observers on high-frequency events. An observer on catalog_product_load_after that makes an API call turns a category page into 24 sequential HTTP requests. I found one making a currency conversion call per product, on a store whose category pages took eleven seconds.
Recursive repository saves. A plugin on ProductRepository::save that modifies the product and calls save again. Sometimes it recurses until memory is exhausted; sometimes it just doubles every write. Both have happened on stores I have been called into.
Disabling core modules to "improve performance". Turning off Magento_Newsletter or the review module because the store does not use them saves a few milliseconds and breaks upgrades in ways that take days to diagnose, because core code has dependencies you cannot see from the module list.
One module for everything. A Vendor_Core with 200 classes covering pricing, shipping, an ERP integration and a theme tweak. Nothing can be disabled independently, nothing can be tested independently, and nothing can be removed when the client stops using that ERP.
No composer.json in the module. It works in app/code, so people skip it. Then the module cannot be versioned, cannot declare dependencies, cannot be installed on another environment reproducibly, and the deployment becomes a file copy.
16. A Worked Example, With the Part That Went Wrong
The trade pricing module for the industrial distributor, rebuilt from the preference-based original. About 8,000 SKUs, four customer groups, contract prices for roughly 340 trade accounts, and volume break rules on top.
Structure. Three modules, not one. Vendor_TradePricing for the pricing engine and its schema, Vendor_TradePricingImport for the nightly contract file, Vendor_TradePricingUi for the admin grid and the storefront badge. The import module depends on the engine and nothing depends on the UI, so the UI can be disabled on a headless deployment without touching anything else.
Extension points used. Two after plugins on the price model, one before plugin validating contract prices on save, two observers for logging and cache invalidation, four preferences — all four binding our own interfaces to our own implementations. Zero core overrides.
Schema. One table, declarative, with a cascade delete to the product table and a unique constraint on group plus product. Three data patches: the boolean attribute, a default config value, and a backfill from the legacy table the old implementation had created with an InstallSchema script.
The migration itself. Ran on a copy of production first. The legacy table had 41,000 rows and 900 of them referenced products that no longer existed — which is exactly why the new table has a foreign key and the old one did not.
What went wrong. The backfill patch. On development data (2,000 rows) it ran in four seconds. On production it loaded each product through the repository to resolve a SKU to an ID, took fifty-one minutes, and held the deployment in maintenance mode for the whole of it because setup:upgrade runs inside the release. The store was down on a Tuesday evening for fifty-one minutes and I had told the client to expect ten.
The fix, which I should have written first: resolve SKUs with one indexed query into a keyed array, then insert in batches of a thousand with insertOnDuplicate. Four seconds on production data. The general rule I took away is that any patch touching more than a few thousand rows should be a queue consumer triggered by the patch rather than work done inside it, so that a slow backfill delays a feature rather than the release.
Numbers afterwards. Product page render with trade pricing active: 340ms before, 190ms after, mostly from removing the per-product currency conversion the old observer was doing. The upgrade to 2.4.6 that had been quoted at eleven weeks took nine working days, of which the module accounted for one. Two subsequent security patches have been applied on the same day they were released, which is the actual point of all of this.
The performance side of that store needed its own work — caching, indexer strategy, Varnish coverage — and that is largely independent of module architecture, though bad modules make it much harder. The specifics are in performance optimisation for Magento and Shopify stores.
17. Testing, and What Is Worth Automating
Magento ships four test suites and most agencies use none of them, which I understand and still think is a mistake.
Unit tests are cheap and worth writing for anything with branching logic. A price calculator with volume breaks and contract overrides has a dozen meaningful cases and they run in under a second with everything mocked. This is where the discipline of injecting interfaces pays off, because a class that calls the object manager cannot be unit tested at all.
Integration tests are slow, need a database, and are the only way to test that your di.xml, your schema and your plugins actually work together. I write them for repositories and for anything involving a save path.
# The checks that belong in CI, cheapest first. The first three
# catch most of what a reviewer would catch, in about a minute.
vendor/bin/phpcs --standard=Magento2 app/code/Vendor
vendor/bin/phpstan analyse -c phpstan.neon app/code/Vendor
vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist \
app/code/Vendor/TradePricing/Test/Unit
# Compilation is the real gate: a circular dependency or a mistyped
# class name in di.xml fails here rather than in production.
bin/magento setup:di:compile
# And the one people skip, which catches schema drift between
# what the module declares and what the database has.
bin/magento setup:db:status
Static analysis at PHPStan level 5 or above on a Magento module finds a specific and useful class of bug: undeclared return types on repository methods, nullable values used without a check, and calls to methods that only exist on a concrete class you have typed against an interface. It complains about Magento's own magic methods and you will spend an afternoon on a baseline, and it is still worth it.
18. Questions I Get Asked
"Is there ever a good reason to override a core class?" Rarely, and the test is whether you are replacing an entire responsibility or patching a detail. Replacing a shipping rate calculator wholesale is a responsibility. Adding two pence to a price is a detail, and details are plugins. If you do override, document why in the class docblock with a link to the issue, and re-check it at every upgrade.
"Plugin or observer for order placement?" Depends what you need. An observer on sales_order_place_after is fine for notifications and integrations, provided it cannot throw. If you need to modify the order before it saves, or to prevent placement, that is a plugin — an observer cannot cleanly stop a save without an exception, and an exception at that point rolls back a transaction where money may already have moved.
"How do I find what plugins are already on a method?" bin/magento dev:di:info 'Magento\Catalog\Model\Product' gives you the resolved configuration including the plugin chain and sort orders. On an unfamiliar store this is the first command I run when something behaves impossibly.
"Why does my plugin not fire?" In order of likelihood: the method is called internally by the same class, the class is instantiated with new, you put the plugin in the wrong area's di.xml, the method is final or protected, or you have not recompiled. The last one is embarrassing and accounts for more of these than I would like.
"Should our module live in app/code or be a Composer package?" Composer package, in a private repository, once you have more than one environment. It gives you versioning, dependency declaration and a reproducible deployment. app/code is fine for a store with one developer and one server, and that describes fewer stores than people think.
"How much does bad module architecture actually cost?" The distributor's number was nine weeks of unbudgeted work and roughly fourteen months of running two versions behind on a platform with published exploits. The security exposure is the part I would emphasise, because a store that cannot take a patch quickly is a store that is one advisory away from a very bad month. The hardening measures in the Magento 2 security hardening checklist all assume you can deploy, and several of them do not help if you cannot.
"Can I use PHP 8 features like readonly and enums?" Yes, subject to the PHP version your Magento release supports. Readonly promoted constructor properties are a good fit for injected dependencies. Enums are useful for status values, though be careful serialising them into anything Magento persists, because the framework's serialisers predate them.
"What is the fastest way to audit a store I have just inherited?" Grep for preferences on the Magento\ namespace, list app/code/Magento for shadowed core modules, run git status against vendor/ if it is tracked, grep for ObjectManager::getInstance, and count around plugins. Half a day, and it tells you most of what the upgrade will cost.
19. What I'd Do First
For a new module, in order.
One. Write the interfaces before the implementations. Api/Data for the entity, Api for the operations. It forces you to decide what the module's contract is before you have code that constrains it, and it is the difference between a module other things can use and a module other things have to work around.
Two. Split it into more modules than feels necessary. Engine, integration, UI. Merging two modules later is an afternoon; splitting one is a rewrite.
Three. Declare every dependency in both module.xml sequence and composer.json, because they do different jobs and only having one produces a module that works on your machine.
Four. Reach for the extension points in this order: existing event, then before or after plugin, then extension attribute, then virtual type, then — having exhausted the others and written down why — a preference or an around plugin.
Five. Put area-specific configuration in area-specific directories. Anything in global di.xml runs on every CLI command and every cron tick, and you will not notice until an indexer gets slow.
Six. Write the data patch's revert method at the same time as the apply method, while you still remember what it did.
Seven. Test any patch that touches data against a production-sized copy before it goes near a release. My fifty-one minutes of unplanned maintenance mode came entirely from skipping this.
Eight. Get setup:di:compile and PHPCS with the Magento2 standard into CI on day one. They cost an hour to configure and they catch the class of mistake that is otherwise found by a customer.
And for an existing store, the single most valuable half-day: audit the preferences. Grep, list, and for each one ask whether it could be a plugin. That list is the price of your next upgrade, and knowing it before someone asks for a quote is worth considerably more than the time it takes to produce.
Suggested & Related Reading
Explore related engineering guides from Kenneth D'Silva:
-
Comprehensive Security Hardening Checklist for Magento 2
Linux file permissions and environment security.
-
Performance Optimization for Magento & Shopify Stores
Redis cache backend and Varnish FPC rules.
-
Headless Commerce Architecture and Magento 2 PWA
Scaling the front-end independently from backend operations.
-
Technical SEO & JSON-LD Structured Data for Magento
Optimizing organic search visibility through semantic data structures.