Skip to content

Magento 2 Decouplifier — Implementation Plan

221 modules scanned. ~121 removable. 12 parallel workstreams for maximum developer throughput.

Reference PR: https://github.com/jakwinkler/magento2/pull/1


How to Read This Plan

  • Workstreams are independent — multiple developers can work on different workstreams simultaneously
  • Tasks within a workstream are sequential — complete them in order
  • Difficulty: T = Trivial, E = Easy, M = Medium, H = Hard
  • Each task lists: what to change, which files, the pattern to follow
  • Patterns are proven — PR #1 already demonstrates each pattern with working code

Completed Work (PR #1)

PR #1 decouples the Catalog module from Bundle, Configurable, Downloadable product types and makes CatalogInventory optional across multiple modules. 77 PHP files changed, 6 XML files changed, 1 new plugin created across 18 modules.

4 Decoupling Patterns Used (Reference for All Future Work)

Pattern A: Hardcoded Type Check → Plugin

Problem: Catalog's CartConfiguration had a hardcoded case TYPE_BUNDLE check.

Before (Catalog/Model/Product/CartConfiguration.php):

switch ($product->getTypeId()) {
    case \Magento\Catalog\Model\Product\Type::TYPE_SIMPLE:
    case \Magento\Catalog\Model\Product\Type::TYPE_VIRTUAL:
        return isset($config['options']);
    case \Magento\Catalog\Model\Product\Type::TYPE_BUNDLE:    // ← hardcoded
        return isset($config['bundle_option']);                // ← hardcoded
}

After: Remove the bundle case from Catalog. Bundle registers its own plugin:

// Bundle/Model/Product/CartConfiguration/Plugin/Bundle.php (NEW FILE)
class Bundle
{
    public function aroundIsProductConfigured(
        CartConfiguration $subject,
        \Closure $proceed,
        Product $product,
        $config
    ) {
        if ($product->getTypeId() === Type::TYPE_CODE) {
            return isset($config['bundle_option']);
        }
        return $proceed($product, $config);
    }
}
<!-- Bundle/etc/di.xml (ADDED) -->
<type name="Magento\Catalog\Model\Product\CartConfiguration">
    <plugin name="bundle_product" type="Magento\Bundle\Model\Product\CartConfiguration\Plugin\Bundle" sortOrder="50" />
</type>

Use this pattern when: A core module has switch/case or if/elseif blocks checking product type IDs, order states, payment methods, etc. Each case moves to the responsible module as a plugin.

Pattern B: Hardcoded DI Array → Module Self-Registration

Problem: Catalog's di.xml listed specific product types in allowedProductTypes arrays.

Before (Catalog/etc/di.xml):

<type name="Magento\Catalog\Model\Product\Price\BasePriceStorage">
    <arguments>
        <argument name="allowedProductTypes" xsi:type="array">
            <item name="0" xsi:type="string">simple</item>
            <item name="1" xsi:type="string">virtual</item>
            <item name="2" xsi:type="string">bundle</item>    <!-- hardcoded -->
        </argument>
    </arguments>
</type>

After: Catalog defines only its own types. Bundle self-registers in its own di.xml:

<!-- Catalog/etc/di.xml — only core types -->
<argument name="allowedProductTypes" xsi:type="array">
    <item name="simple" xsi:type="string">simple</item>
    <item name="virtual" xsi:type="string">virtual</item>
</argument>

<!-- Bundle/etc/di.xml — adds itself -->
<type name="Magento\Catalog\Model\Product\Price\BasePriceStorage">
    <arguments>
        <argument name="allowedProductTypes" xsi:type="array">
            <item name="bundle" xsi:type="string">bundle</item>
        </argument>
    </arguments>
</type>

Use this pattern when: A core module's di.xml has arrays listing product types, entity types, or feature flags that belong to other modules. Each module adds its own entry via DI merging.

Pattern C: Hard Dependency → Nullable Constructor (Optional Dep)

Problem: Quote/Model/Cart/AddProductsToCart.php required StockRegistryInterface in constructor.

Before:

public function __construct(
    // ...
    private readonly StockRegistryInterface $stockRegistry
) {}

After:

public function __construct(
    // ...
    private readonly ?StockRegistryInterface $stockRegistry = null
) {}

// Usage: null-check before calling
if ($product && $this->stockRegistry !== null) {
    $stockItem = $this->stockRegistry->getStockItem(...);
}

Use this pattern when: A module uses another module's service for a secondary feature (e.g., stock check during add-to-cart). The primary functionality works without it; the feature degrades gracefully.

Pattern D: Move DI Config to Correct Module

Problem: Downloadable/etc/di.xml configured a class from GiftMessageGraphQl.

Before (Downloadable/etc/di.xml):

<type name="Magento\GiftMessageGraphQl\Model\Resolver\Product\GiftMessage">
    <arguments>
        <argument name="nonGiftMessageProductTypes" xsi:type="array">
            <item name="downloadable" xsi:type="string">downloadable</item>
        </argument>
    </arguments>
</type>

After: Removed from Downloadable, added to GiftMessageGraphQl/etc/graphql/di.xml.

Use this pattern when: Module A configures a class from Module B, but Module B depends on Module A (or should). The config belongs in the module that owns the class, or the module whose feature it is.

Modules Changed in PR #1

Module What Changed Pattern Used
Catalog Removed Bundle/Configurable/Downloadable type checks from CartConfiguration, Option\SaveHandler, InvalidSkuProcessor, TierPriceValidator, CategorySetup, di.xml arrays. Added empty injectable arrays for extension. A, B
Bundle Added CartConfiguration\Plugin\Bundle, self-registered in 6 DI argument arrays for pricing, reporting, options, EAV A, B
ConfigurableProduct Self-registered in DI arrays (di.xml), decoupled stock status processor B, C
GroupedProduct Self-registered in DI arrays, made QuantityValidator optional B, C
Downloadable Moved GiftMessageGraphQl DI config out, added own Plugin/Downloadable for product init D, A
CatalogInventory consumers (12 modules) Made StockRegistryInterface, StockConfigurationInterface, StockHelper nullable in constructors C
Sales Made Order state/status handling configurable via DI, removed hardcoded checks B, C
Dhl, Ups, Shipping Made stock registry optional in carrier models C
5 composer.json Removed phantom magento/module-catalog-inventory deps Phantom removal

Workstream 1: Phantom Dependency Removal

Developer count: 1 Difficulty: T Prerequisite: None

Remove require entries from composer.json where zero code references exist.

Task 1.1: Already Completed in PR #1

AdvancedPricingImportExport, Fedex, Reports, Shipping, Usps — removed magento/module-catalog-inventory.

Task 1.2: Remaining Phantom Dependencies

Module Dependency to Remove Verify with
Usps/composer.json magento/module-sales grep -r "Magento\\\\Sales\|Magento.Sales" app/code/Magento/Usps/ --include="*.php" returns nothing
SalesRule/composer.json magento/module-payment Zero PHP imports from Payment in production code
SalesRule/composer.json magento/module-shipping Zero PHP imports from Shipping in production code
Reports/composer.json magento/module-downloadable Only hardcoded string 'downloadable' — not a class dep
Msrp/composer.json magento/module-downloadable Already removed in PR #1

Workstream 2: Plugin Relocation

Developer count: 1-2 Difficulty: E Prerequisite: None

Pattern used: D (Move DI Config to Correct Module)

Task 2.1: LoginAsCustomer — Move Persistent Plugin

Step Action
1 Copy Persistent/Model/Plugin/LoginAsCustomerCleanUp.phpLoginAsCustomer/Plugin/Persistent/CleanUp.php
2 Update namespace in new file
3 Move plugin registration from Persistent/etc/frontend/di.xml to LoginAsCustomer/etc/frontend/di.xml
4 Remove old file and DI registration from Persistent
5 Remove magento/module-login-as-customer-api from Persistent/composer.json
6 Add magento/module-persistent to LoginAsCustomer/composer.json suggest
7 Run bin/magento setup:di:compile

Task 2.2: Multishipping — Move GiftMessage Plugins (2 files)

Step Action
1 Copy GiftMessage/Block/Message/Multishipping/Plugin/ItemsBox.phpMultishipping/Plugin/GiftMessage/ItemsBox.php
2 Copy GiftMessage/Model/Type/Plugin/Multishipping.phpMultishipping/Plugin/GiftMessage/TypePlugin.php
3 Update namespaces in new files
4 Move 2 plugin registrations from GiftMessage/etc/frontend/di.xml to Multishipping/etc/frontend/di.xml
5 Remove old files and DI registrations from GiftMessage
6 Remove magento/module-multishipping from GiftMessage/composer.json
7 Add magento/module-gift-message to Multishipping/composer.json suggest

Task 2.3: Multishipping — Move SalesRule Plugin (1 file)

Step Action
1 Copy SalesRule/Plugin/CouponUsagesIncrementMultishipping.phpMultishipping/Plugin/SalesRule/CouponUsagesIncrement.php
2 Update namespace
3 Move plugin registration from SalesRule/etc/di.xml to Multishipping/etc/di.xml
4 Remove old file and DI registration from SalesRule
5 Remove magento/module-multishipping from SalesRule/composer.json

Task 2.4: Customer — Move Wishlist Plugin Registration

Step Action
1 Move plugin registration from Customer/etc/di.xml to Wishlist/etc/di.xml
2 (Plugin class already lives in Wishlist — only the registration is misplaced)

Task 2.5: Persistent — Fix Cron Type-Hint

In Persistent/Observer/ClearExpiredCronJobObserver.php:

// Before:
use Magento\Cron\Model\Schedule;
public function execute(Schedule $schedule)

// After:
public function execute($schedule)

Remove magento/module-cron from Persistent/composer.json.


Workstream 3: Newsletter Decoupling

Developer count: 2 (one admin, one frontend) Difficulty: M Prerequisite: None Pattern used: A (hardcoded checks → plugins), D (move UI to correct module)

Task 3.1: Move Admin Newsletter Blocks (Developer A — Admin)

FROM (Customer) TO (Newsletter)
Block/Adminhtml/Edit/Tab/Newsletter.php Block/Adminhtml/Customer/Tab/Newsletter.php
Block/Adminhtml/Edit/Tab/Newsletter/Grid.php Block/Adminhtml/Customer/Tab/Newsletter/Grid.php
Block/Adminhtml/Edit/Tab/Newsletter/Grid/Filter/Status.php Block/Adminhtml/Customer/Tab/Newsletter/Grid/Filter/Status.php
view/adminhtml/templates/tab/newsletter.phtml Newsletter/view/adminhtml/templates/customer/tab/newsletter.phtml
view/adminhtml/layout/customer_index_newsletter.xml Newsletter/view/adminhtml/layout/customer_index_newsletter.xml

Update namespaces, template paths. Verify admin customer edit → newsletter tab works.

Task 3.2: Move Admin Mass Actions (Developer A — Admin)

FROM (Customer) TO (Newsletter)
Controller/Adminhtml/Index/MassSubscribe.php Newsletter/Controller/Adminhtml/Customer/MassSubscribe.php
Controller/Adminhtml/Index/MassUnsubscribe.php Newsletter/Controller/Adminhtml/Customer/MassUnsubscribe.php

Update route definitions. Verify admin customer grid mass actions work.

Task 3.3: Refactor Customer Registration (Developer B — Frontend)

Customer/Block/Form/Register.php: Remove Newsletter\Model\Config. Newsletter contributes the checkbox via layout XML child block on handle customer_account_create.

Customer/Controller/Account/CreatePost.php: Remove SubscriberFactory. Newsletter observes customer_register_success event.

Create Newsletter/view/frontend/layout/customer_account_create.xml:

<referenceContainer name="form.additional.info">
    <block class="Magento\Newsletter\Block\Subscribe" 
           name="customer.form.register.newsletter" 
           template="Magento_Newsletter::form/register/newsletter.phtml"/>
</referenceContainer>

Task 3.4: Refactor Customer Dashboard (Developer B — Frontend)

Customer/Block/Account/Dashboard.php and Dashboard/Info.php: Remove SubscriberFactory. Read is_subscribed from customer extension attributes (already set by Newsletter's CustomerPlugin).

Task 3.5: Refactor Admin Save Controller

Customer/Controller/Adminhtml/Index/Save.php: Remove SubscriberFactory and SubscriptionManagerInterface. Newsletter's CustomerPlugin::afterSave() already handles this.

Task 3.6: Remove Composer Dependencies

Remove magento/module-newsletter from: Customer/composer.json, Review/composer.json. Move to suggest in CustomerGraphQl/composer.json.


Workstream 4: Review Decoupling

Developer count: 1 Difficulty: M Prerequisite: None Pattern used: D (move code to correct module)

Task 4.1: Move Review Report Collections (2 files)

FROM (Reports) TO (Review)
Model/ResourceModel/Review/Collection.php Review/Model/ResourceModel/Report/Collection.php
Model/ResourceModel/Review/Customer/Collection.php Review/Model/ResourceModel/Report/Customer/Collection.php

Task 4.2: Move Review Report Controllers (10 files)

Move entire Reports/Controller/Adminhtml/Report/Review/ directory → Review/Controller/Adminhtml/Report/

Update route definitions to maintain same admin URLs.

Task 4.3: Move Review Report Blocks (4 files)

Move Reports/Block/Adminhtml/Review/Review/Block/Adminhtml/Report/

Task 4.4: Move ACL Resources

Move review-related ACL entries from Reports/etc/acl.xml to Review/etc/acl.xml.

Task 4.5: Remove Composer Dependency

Remove magento/module-review from Reports/composer.json.


Workstream 5: Wishlist Decoupling

Developer count: 2 (one admin, one frontend) Difficulty: M Prerequisite: None Pattern used: A (hardcoded → plugin), D (move UI)

Task 5.1: Move Admin Controllers from Customer (Developer A)

Move 5 files from Customer/Controller/Adminhtml/*Wishlist* to Wishlist/Controller/Adminhtml/Customer/.

Task 5.2: Decouple Sales AdminOrder\Create (Developer A)

Extract getCustomerWishlist() from Sales/Model/AdminOrder/Create.php into a Wishlist plugin. The method uses ObjectManager to create Wishlist\Model\Wishlist — move this to Wishlist/Plugin/Sales/AdminOrder/CreatePlugin.php.

Task 5.3: Fix Catalog/CatalogWidget Templates (Developer B)

Replace $this->helper(Magento\Wishlist\Helper\Data::class)->isAllow() in templates with a named child block:

// Before (in Catalog templates):
<?php if ($this->helper(Magento\Wishlist\Helper\Data::class)->isAllow() && $showWishlist): ?>

// After:
<?= $block->getChildHtml('product.addto.wishlist') ?>

Wishlist contributes the child block via layout XML.

Task 5.4: Update Composer Dependencies

Remove magento/module-wishlist from: Customer, Sales, Catalog, CatalogWidget. Move to suggest in: GroupedProduct, Reports.


Workstream 6: MSRP Decoupling

Developer count: 1 Difficulty: M Prerequisite: None Pattern used: A (hardcoded → plugin)

Task 6.1: Catalog FinalPriceBox → Msrp Plugin

Move MSRP logic from Catalog/Pricing/Render/FinalPriceBox.php to Msrp/Plugin/Catalog/Pricing/Render/FinalPriceBoxPlugin.php.

Remove use Magento\Msrp\Pricing\Price\MsrpPrice and isMsrpPriceApplicable() from FinalPriceBox.

Task 6.2: Checkout DefaultItem → Msrp Plugin

Move canApplyMsrp flag from Checkout/CustomerData/DefaultItem.php to Msrp/Plugin/Checkout/CustomerData/DefaultItemPlugin.php.

Remove $msrpHelper from DefaultItem constructor.

Task 6.3: GroupedProduct → MsrpGroupedProduct

Move $msrpData and getChildrenMsrp() from GroupedProduct/Model/Product/Type/Grouped.php to MsrpGroupedProduct as a plugin.

Task 6.4: Remove Composer Dependencies

Remove magento/module-msrp from Catalog/composer.json and Checkout/composer.json.


Workstream 7: Payment ↔ Sales Circular Dependency

Developer count: 1-2 Difficulty: H Prerequisite: None Pattern used: D (move adapters to correct module)

Task 7.1: Move Gateway Adapters from Payment to Sales

FROM (Payment) TO (Sales)
Gateway/Data/Order/OrderAdapter.php Sales/Model/Payment/Gateway/OrderAdapter.php
Gateway/Data/Order/OrderAdapterFactory.php Sales/Model/Payment/Gateway/OrderAdapterFactory.php
Gateway/Data/Order/AddressAdapter.php Sales/Model/Payment/Gateway/AddressAdapter.php
Gateway/Data/Order/AddressAdapterFactory.php Sales/Model/Payment/Gateway/AddressAdapterFactory.php

Add DI preferences in Sales/etc/di.xml.

Task 7.2: Refactor PaymentDataObjectFactory

Replace instanceof Sales\Model\Order\Payment with a check that doesn't import Sales.

Task 7.3: Move Observers from Payment to Sales

FROM (Payment) TO (Sales)
Observer/SalesOrderBeforeSaveObserver.php Sales/Observer/Payment/OrderBeforeSaveObserver.php
Observer/UpdateOrderStatusForPaymentMethodsObserver.php Sales/Observer/Payment/UpdateOrderStatusObserver.php

Move event registrations from Payment/etc/events.xml to Sales/etc/events.xml.

Task 7.4: Remove Sales Dependency from Payment

Remove magento/module-sales from Payment/composer.json.


Workstream 8: CatalogInventory — Product Type Stock Abstraction

Developer count: 1-2 Difficulty: H Prerequisite: PR #1 merged (Pattern C already applied to light-usage modules) Pattern used: New — abstract base class for shared stock logic

Task 8.1: Create Abstract ChangeParentStockStatus

Create CatalogInventory/Model/Inventory/AbstractChangeParentStockStatus.php containing the shared code (identical in Bundle, Configurable, Grouped):

abstract class AbstractChangeParentStockStatus
{
    // Shared: getStockItems(), isNeedToUpdateParent(), saveParentStockStatus()
    // Abstract: getParentIds(array $childrenIds), isChildrenInStock(int $parentId)
}

Task 8.2: Refactor Bundle ChangeParentStockStatus

Bundle/Model/Inventory/ChangeParentStockStatus.php → extends abstract. Only implements isChildrenInStock() (per-option check).

Task 8.3: Refactor Configurable ChangeParentStockStatus

Same pattern. Only implements the flat "any child in stock" check.

Task 8.4: Refactor Grouped ChangeParentStockStatus

Same pattern. Nearly identical to Configurable.


Workstream 9: Framework Async Publisher

Developer count: 1 Difficulty: M Prerequisite: None

Task 9.1: Create Interface

// lib/internal/Magento/Framework/Async/PublisherInterface.php
namespace Magento\Framework\Async;

interface PublisherInterface
{
    public function publish(string $topicName, mixed $data): void;
}

Task 9.2: Create MQ-backed Implementation

MessageQueue/Model/Async/Publisher.php — delegates to Framework\MessageQueue\PublisherInterface.

Task 9.3: Create Synchronous Fallback

Framework/Async/SynchronousPublisher.php — runs handler inline when MQ is absent.

Task 9.4: Migrate Light-Usage Modules (9 modules)

Replace use Magento\Framework\MessageQueue\PublisherInterface with use Magento\Framework\Async\PublisherInterface in: ProductAlert, MediaStorage, MediaGallerySynchronization, MediaGalleryRenditions, MediaContentSynchronization, ImportExport, CatalogUrlRewrite, Eav, Config.


Workstream 10: Framework PHP 8.4 Quick Wins

Developer count: 1-2 Difficulty: T-E Prerequisite: PHP 8.4 runtime

Task 10.1: @deprecated#[\Deprecated] (401 annotations, 196 files)

Mechanical — can be scripted:

# Find and replace pattern
grep -rn "@deprecated" lib/internal/Magento/Framework/ --include="*.php" -l

Task 10.2: json_validate() Replacement (3-5 files)

Replace json_decode() + json_last_error() with json_validate() in Framework/Serialize/JsonValidator.php and similar.

Task 10.3: Pure Constant Classes → Enums (99 files)

Start with the simplest: classes with ONLY constants, zero methods. Keep old interfaces with @deprecated for backward compat.


Workstream 11: Framework PHP 8.4 Proxy Elimination

Developer count: 1-2 Difficulty: H Prerequisite: PHP 8.4, Workstream 10 complete

Task 11.1: Modify ObjectManager Proxy Resolution

Change Framework/ObjectManager/Factory/AbstractFactory.php to use ReflectionClass::newLazyProxy() instead of generated Proxy classes.

Task 11.2: Remove Proxy Generator

Remove Framework/ObjectManager/Code/Generator/Proxy.php from generatedEntities config.

Task 11.3: Verify All 206 Proxy References

Test that every \Proxy reference in di.xml across the codebase resolves correctly with native lazy objects.


Workstream 12: View Layout — Lazy Blocks

Developer count: 1 Difficulty: M Prerequisite: PHP 8.4

Task 12.1: Wrap Block Creation in LazyGhost

Modify Framework/View/Layout/Generator/Block.php:

// Before:
$block = $this->blockFactory->createBlock($class, $arguments);

// After:
$reflector = new ReflectionClass($class);
$block = $reflector->newLazyGhost(function ($instance) use ($class, $arguments) {
    $this->blockFactory->initializeBlock($instance, $arguments);
});

Task 12.2: Verify Block Rendering

Test that all block types render correctly — the lazy ghost initializes transparently on toHtml().


Workstream Dependencies

WS 1  (Phantom deps)          ─── independent
WS 2  (Plugin relocation)     ─── independent
WS 3  (Newsletter)            ─── independent
WS 4  (Review)                ─── independent
WS 5  (Wishlist)              ─── independent
WS 6  (MSRP)                  ─── independent
WS 7  (Payment↔Sales)         ─── independent
WS 8  (Stock abstraction)     ─── depends on PR #1 merged
WS 9  (Async Publisher)       ─── independent
WS 10 (PHP 8.4 quick wins)    ─── independent
WS 11 (Proxy elimination)     ─── depends on WS 10
WS 12 (Lazy blocks)           ─── depends on PHP 8.4

10 of 12 workstreams can start Day 1. WS 8 waits on PR #1. WS 11 waits on WS 10.


Verification Checklist (After Each Task)

# 1. Compilation
bin/magento setup:di:compile

# 2. Database
bin/magento setup:upgrade --dry-run

# 3. Unit tests for affected modules
vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist app/code/Magento/MODULE/Test/Unit/

# 4. Module disable test (for newly decoupled modules)
bin/magento module:disable Magento_MODULE
bin/magento setup:di:compile  # Must pass with module disabled
bin/magento module:enable Magento_MODULE

Summary

WS Workstream Tasks Files Difficulty Devs Status
PR #1: Catalog/Bundle/CatalogInventory decoupling 77 PHP + 6 XML M DONE
1 Phantom deps 2 ~5 composer.json T 1 Ready
2 Plugin relocation 5 ~12 files E 1 Ready
3 Newsletter 6 ~15 files M 2 Ready
4 Review 5 ~16 files M 1 Ready
5 Wishlist 4 ~12 files M 2 Ready
6 MSRP 4 ~6 files M 1 Ready
7 Payment↔Sales 4 ~8 files H 1-2 Ready
8 Stock abstraction 4 ~6 files H 1-2 Blocked (PR #1)
9 Async Publisher 4 ~12 files M 1 Ready
10 PHP 8.4 quick wins 3 ~300 files T-E 1-2 Ready
11 Proxy elimination 3 ~5 files H 1-2 Blocked (WS 10)
12 Lazy blocks 2 ~2 files M 1 Ready
Total 46 ~500 files 10-15 devs