Skip to content
beginner Phase 116 · Beginner Projects

Project: Build a Custom Magento 2 Module

Complete lifecycle of building a custom Magento module from scratch - registration, configuration, models, blocks, and tests

1h
2 problems
Topic Progress 0%

Module Setup

Module Structure

Vendor/CustomWishlist/
├── etc/
│   ├── module.xml
│   ├── registration.php
│   ├── di.xml
│   ├── routes.xml
│   └── acl.xml
├── Block/
│   └── Product/
│       └── View.php
├── Controller/
│   └── Index
│       └── Add.php
├── Model/
│   ├── ResourceModel/
│   │   └── Wishlist.php
│   └── Wishlist.php
├── view/
│   ├── frontend/
│   │   ├── layout/
│   │   │   └── catalog_product_view.xml
│   │   ├── templates/
│   │   │   └── wishlist.phtml
│   │   └── web/
│   │       └── js/
│   │           └── wishlist.js
│   └── adminhtml/
│       └── layout/
├── Setup/
│   └── InstallSchema.php
├── Test/
│   └── Unit/
│       └── Model/
│           └── WishlistTest.php
└── composer.json

registration.php

<?php
\Magento\Framework\Component\ComponentRegistrar::register(
    \Magento\Framework\Component\ComponentRegistrar::MODULE,
    'Vendor_CustomWishlist',
    __DIR__
);

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_CustomWishlist" setup_version="1.0.0">
        <sequence>
            <module name="Magento_Catalog"/>
            <module name="Magento_Customer"/>
        </sequence>
    </module>
</config>

composer.json

{
    "name": "vendor/module-custom-wishlist",
    "description": "Custom Wishlist Module",
    "type": "magento2-module",
    "version": "1.0.0",
    "require": {
        "php": ">=8.1",
        "magento/framework": "^103.0",
        "magento/module-catalog": "^103.0",
        "magento/module-customer": "^103.0"
    },
    "autoload": {
        "files": ["registration.php"],
        "psr-4": {
            "Vendor\\CustomWishlist\\": ""
        }
    }
}

The module structure follows Magento 2 conventions: etc/ for configuration, Block/ for presentation logic, Model/ for business logic, and view/ for frontend templates.

Model Implementation

Wishlist Model

Model Class

<?php
namespace Vendor\CustomWishlist\Model;

use Magento\Framework\Model\AbstractModel;
use Vendor\CustomWishlist\Model\ResourceModel\Wishlist as WishlistResource;

class Wishlist extends AbstractModel
{
    const ID_FIELD_NAME = 'wishlist_id';
    
    protected function _construct()
    {
        $this->_init(WishlistResource::class);
    }
    
    public function getId()
    {
        return $this->getData(self::ID_FIELD_NAME);
    }
    
    public function getCustomerId()
    {
        return $this->getData('customer_id');
    }
    
    public function setCustomerId($customerId)
    {
        return $this->setData('customer_id', $customerId);
    }
    
    public function getProductId()
    {
        return $this->getData('product_id');
    }
    
    public function setProductId($productId)
    {
        return $this->setData('product_id', $productId);
    }
    
    public function getAddedAt()
    {
        return $this->getData('added_at');
    }
}

Resource Model

<?php
namespace Vendor\CustomWishlist\Model\ResourceModel;

use Magento\Framework\Model\ResourceModel\Db\AbstractDb;

class Wishlist extends AbstractDb
{
    protected function _construct()
    {
        $this->_init('vendor_custom_wishlist', 'wishlist_id');
    }
}

Collection

<?php
namespace Vendor\CustomWishlist\Model\ResourceModel\Wishlist;

use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
use Vendor\CustomWishlist\Model\Wishlist as WishlistModel;
use Vendor\CustomWishlist\Model\ResourceModel\Wishlist as WishlistResource;

class Collection extends AbstractCollection
{
    protected function _construct()
    {
        $this->_init(WishlistModel::class, WishlistResource::class);
    }
    
    public function getCustomerWishlist($customerId)
    {
        $this->addFieldToFilter('customer_id', $customerId);
        return $this;
    }
}

The Model-ResourceModel-Collection pattern separates data representation (Model) from database operations (ResourceModel) from query building (Collection).

Block and Template

Block Class

<?php
namespace Vendor\CustomWishlist\Block\Product;

use Magento\Framework\View\Element\Template;
use Magento\Catalog\Model\Product;
use Vendor\CustomWishlist\Api\WishlistRepositoryInterface;
use Magento\Customer\Model\Session as CustomerSession;

class View extends Template
{
    public function __construct(
        Template\Context $context,
        private WishlistRepositoryInterface $wishlistRepository,
        private CustomerSession $customerSession,
        array $data = []
    ) {
        parent::__construct($context, $data);
    }
    
    public function getProduct()
    {
        return $this->getData('product');
    }
    
    public function isInWishlist()
    {
        $product = $this->getProduct();
        $customerId = $this->customerSession->getCustomerId();
        
        if (!$customerId || !$product) {
            return false;
        }
        
        $wishlist = $this->wishlistRepository->getCustomerWishlist($customerId);
        
        foreach ($wishlist as $item) {
            if ($item->getProductId() == $product->getId()) {
                return true;
            }
        }
        
        return false;
    }
    
    public function getAddToWishlistUrl()
    {
        return $this->getUrl('customwishlist/index/add');
    }
}

Layout XML

<!-- view/frontend/layout/catalog_product_view.xml -->
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="product.info.extrahint">
            <block class="Vendor\CustomWishlist\Block\Product\View"
                   name="custom.wishlist.button"
                   template="Vendor_CustomWishlist::wishlist.phtml"
                   after="-"/>
        </referenceContainer>
    </body>
</page>

Template

<!-- view/frontend/templates/wishlist.phtml -->
<?php /** @var Vendor\CustomWishlist\Block\Product\View $block */ ?>
<div class="custom-wishlist">
    <a href="<?php echo $block->getAddToWishlistUrl(); ?>"
       class="action towishlist <?php echo $block->isInWishlist() ? 'in-wishlist' : ''; ?>"
       data-action="add-to-wishlist"
       role="add to wishlist">
        <span><?php echo $block->isInWishlist() ? 'In Wishlist' : 'Add to Wishlist'; ?></span>
    </a>
</div>

Blocks provide data to templates via getter methods. Layout XML controls where the block renders on the page.

Testing

Unit Test

<?php
namespace Vendor\CustomWishlist\Test\Unit\Model;

use PHPUnit\Framework\TestCase;
use Vendor\CustomWishlist\Model\Wishlist;
use Vendor\CustomWishlist\Model\ResourceModel\Wishlist as WishlistResource;

class WishlistTest extends TestCase
{
    private $wishlist;
    private $resourceMock;
    
    protected function setUp(): void
    {
        $this->resourceMock = $this->createMock(WishlistResource::class);
        
        $this->wishlist = new Wishlist(
            $this->resourceMock
        );
    }
    
    public function testGetSetCustomerId()
    {
        $customerId = 123;
        $this->wishlist->setCustomerId($customerId);
        $this->assertEquals($customerId, $this->wishlist->getCustomerId());
    }
    
    public function testGetSetProductId()
    {
        $productId = 456;
        $this->wishlist->setProductId($productId);
        $this->assertEquals($productId, $this->wishlist->getProductId());
    }
    
    public function testResourceInit()
    {
        $this->resourceMock->expects($this->once())
            ->method('load')
            ->with($this->wishlist, null);
        
        $this->wishlist->load();
    }
}

Integration Test

<?php
namespace Vendor\CustomWishlist\Test\Integration\Model;

use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
use Vendor\CustomWishlist\Model\WishlistFactory;
use Vendor\CustomWishlist\Api\WishlistRepositoryInterface;

class WishlistRepositoryTest extends TestCase
{
    private $repository;
    private $factory;
    
    protected function setUp(): void
    {
        $this->repository = Bootstrap::getObjectManager()->create(WishlistRepositoryInterface::class);
        $this->factory = Bootstrap::getObjectManager()->create(WishlistFactory::class);
    }
    
    public function testSaveAndRetrieve()
    {
        $wishlist = $this->factory->create();
        $wishlist->setCustomerId(1);
        $wishlist->setProductId(100);
        
        $this->repository->save($wishlist);
        
        $retrieved = $this->repository->getCustomerWishlist(1);
        $this->assertNotEmpty($retrieved);
    }
    
    public function testDelete()
    {
        $wishlist = $this->factory->create();
        $wishlist->setCustomerId(2);
        $wishlist->setProductId(200);
        
        $this->repository->save($wishlist);
        $this->repository->delete($wishlist);
        
        $retrieved = $this->repository->getCustomerWishlist(2);
        $this->assertEmpty($retrieved);
    }
}

Unit tests mock dependencies for fast isolated testing. Integration tests verify real database operations through Magento's object manager.

Business Context, Architecture, and Production Considerations

Business Requirements Context

Who is the Customer?

Custom Magento modules are built by development teams for:

  • Internal IT teams at retailers who need functionality not available in the marketplace (custom loyalty programs, ERP integrations)
  • Agencies building bespoke features for merchant clients
  • ISVs (Independent Software Vendors) creating marketplace extensions for sale on the Magento Marketplace
  • System integrators connecting Magento to external systems (WMS, PIM, CRM)

What Problem Does This Solve?

  1. Feature gap: Magento core + marketplace extensions don't cover 100% of business requirements; ~30% of Magento implementations require at least one custom module
  2. Business process automation: Custom modules encode unique business rules (loyalty tiers, custom pricing, specialized checkout flows)
  3. System integration: Connecting Magento to ERP (SAP, NetSuite), WMS (ShipStation), or CRM (Salesforce) requires custom API modules
  4. Competitive differentiation: Custom features (personalization engines, configurators) create unique shopping experiences
  5. Compliance: Industry-specific regulations (pharma, automotive) require custom data capture and validation

Architecture Decisions

Decision 1: Preference vs Plugin vs Observer

Alternative Use Case Pros Cons Decision
Plugin (Interceptor) Modify existing class behavior Chainable, upgrade-safe, configurable Only works on public methods Selected for most cases - safest pattern
Observer (Event) React to system events Loose coupling, many extension points No return value control, hard to debug Use for event-driven scenarios
Preference (Override) Replace class entirely Full control Breaks upgrade path, maintenance burden Avoid unless absolutely necessary
Virtual Type Alternative DI configuration No code changes Limited to constructor args Use for testing/DI wiring

Decision 2: InstallSchema vs DB Schema (declarative)

Alternative Pros Cons Decision
Declarative (db_schema.xml) Upgrade-safe, readable, no PHP needed Magento 2.3+ only, limited DDL support Selected for new modules
InstallSchema.php Full DDL control, complex migrations possible Upgrade conflicts, manual versioning Legacy only
DataPatch Versioned data migrations More verbose Use for data changes

Decision 3: API-First vs Direct Model Access

Alternative Pros Cons Decision
REST/GraphQL API (Api) Decoupled, reusable by frontend, testable Extra code, more planning Selected for customer-facing features
Direct Model/Repository in Block Simpler, fewer files Tightly coupled, harder to test Avoid in blocks
Service Contract (Api) Clean interface, plugin-friendly Overkill for simple modules Use for complex integrations

Decision 4: Module Packaging

Alternative Pros Cons Decision
Composer package Version control, dependency management, marketplace compatible Requires composer knowledge Selected for all production modules
Manual copy (app/code/) Quick setup No version control, no upgrade path Development only
Magento Marketplace Distribution, revenue Review process, compliance requirements For ISVs

Production Deployment Checklist

Pre-Deployment

  • Run php bin/magento module:status to verify module state
  • Run full test suite (unit + integration)
  • Run php bin/magento setup:dry-run to preview schema changes
  • Verify composer.json dependencies are compatible
  • Check ACL permissions for admin features
  • Verify database backup exists before schema changes
  • Test module enable/disable cycle (ensure clean uninstall)
  • Review di.xml for circular dependencies

Deployment

  • Enable maintenance mode: php bin/magento maintenance:enable
  • Deploy code via composer or CI/CD
  • Run php bin/magento setup:upgrade
  • Run php bin/magento setup:di:compile
  • Run php bin/magento setup:static-content:deploy
  • Disable maintenance mode: php bin/magento maintenance:disable
  • Clear all caches: php bin/magento cache:clean && cache:flush

Post-Deployment

  • Verify module appears in php bin/magento module:status
  • Test core functionality (add to wishlist, check persistence)
  • Verify admin ACL works (if applicable)
  • Check exception.log for errors
  • Verify cron jobs run if module registers any
  • Test module disable/enable without data loss

Monitoring and Alerting

Module Health Metrics

Custom table row count           → Track growth rate
Controller request volume        → Endpoint usage
Block render time                → Template performance
Cache hit ratio for module data  → Redis/Memcached efficiency
Exception count in module code   → Error rate

Alerting Thresholds

Metric Warning Critical Action
Module exception rate > 0.1% > 1% Check logs, trace error
Custom table size > 1M rows > 5M rows Implement archiving
Controller response time > 500ms > 2s Optimize queries, check indexes
Cache miss ratio > 30% > 50% Review cache config

Logging

// In your module's code
use Psr\Log\LoggerInterface;

protected $logger;

public function __construct(LoggerInterface $logger)
{
    $this->logger = $logger;
}

public function riskyOperation(): void
{
    try {
        // operation
    } catch (\Exception $e) {
        $this->logger->error('CustomWishlist error: ' . $e->getMessage(), [
            'exception' => $e,
            'module' => 'Vendor_CustomWishlist',
        ]);
        throw $e;
    }
}

Use Magento's PSR-3 logger to write to var/log/exception.log. Monitor via ELK stack or CloudWatch.

Cost Estimation

Development Cost

Item Hours Rate Cost
Module structure + registration 2 $100/hr $200
Model + ResourceModel + Collection 4 $100/hr $400
Block + Template + Layout 3 $100/hr $300
Controller + Routes 2 $100/hr $200
Unit + Integration tests 3 $100/hr $300
Code review + QA 2 $100/hr $200
Total Development 16 $1,600

Infrastructure Cost (Monthly)

Component Cost
Database (custom table storage) ~$2/mo
No additional hosting needed $0
No additional licenses $0
Total Monthly ~$2/mo

Ongoing Maintenance

Item Frequency Cost
Magento version compatibility testing Quarterly $200
Security patches As needed $100-300
Feature enhancements As needed $500-2,000
Annual Maintenance $2,400-$10,000

Cost vs Marketplace Extension

Factor Custom Module Marketplace Extension
Upfront cost $1,600 $50-500
Customization fit 100% 60-80%
Upgrade risk Managed by team Third-party dependency
Support Internal team Extension vendor
Long-term flexibility High Low

Practice Problems

0 / 1 solved
Build Custom Module

Build a complete custom module that allows customers to save products to a personal list.

Quiz

1. What file registers a Magento module?

Question 1 options

2. What does module.xml define?

Question 2 options

3. What is the purpose of di.xml?

Question 3 options

4. How to enable a custom module?

Question 4 options

5. Which pattern is safest for modifying existing class behavior without breaking upgrades?

Question 5 options

6. What command should you run before deploying a module with schema changes?

Question 6 options

Flashcards

Question

What files register a module?

Answer

registration.php and module.xml

Question

What does di.xml configure?

Answer

Dependency injection: preferences, arguments, plugins

Question

What is a resource model?

Answer

Handles database operations for a model

Question

What is a collection?

Answer

Group of models with filtering and sorting capabilities

Question

How to run module tests?

Answer

vendor/bin/phpunit with appropriate config file

Question

When should you use a Plugin over a Preference?

Answer

Plugin is upgrade-safe and chains around methods; Preference replaces the class entirely

Question

What is the recommended schema approach for new modules?

Answer

Declarative schema (db_schema.xml) for upgrade-safety

Question

What is the typical development cost for a simple custom module?

Answer

~$1,600 (16 hours at $100/hr)

Revision Notes

Key Takeaways

  • 1. Module structure: registration.php, module.xml, composer.json, etc/
  • 2. Models handle data, ResourceModels handle database, Collections group models
  • 3. Blocks provide data to templates, Templates render HTML
  • 4. Controllers handle HTTP requests and responses
  • 5. Always write tests: unit tests for logic, integration tests for database
  • 6. Enable module with 'php bin/magento module:enable'
  • 7. Plugins are preferred over Preferences for modifying behavior (upgrade-safe)
  • 8. Use declarative schema (db_schema.xml) for new modules
  • 9. Use Magento's PSR-3 logger for module-specific logging

Interview Tips

  • Walk through creating a Magento module from scratch
  • Explain the difference between Model, ResourceModel, and Collection
  • How do you test a Magento module?
  • Describe the request lifecycle in your module
  • What is the purpose of di.xml?
  • Compare Plugin vs Preference vs Observer patterns
  • Explain declarative schema vs InstallSchema

Cheat Sheet

Custom Module Cheat Sheet

Files:

  • registration.php: register module
  • module.xml: metadata + dependencies
  • di.xml: dependency injection
  • composer.json: autoloading

Structure:

  • Model/: data classes
  • ResourceModel/: DB operations
  • Block/: business logic for templates
  • Controller/: HTTP handlers
  • view/: templates + layout

Patterns:

  • Plugin: modify behavior, upgrade-safe
  • Observer: react to events, loose coupling
  • Preference: replace class, break upgrades

Schema:

  • db_schema.xml: declarative (preferred)
  • InstallSchema.php: legacy
  • DataPatch: versioned data migrations

Test:

  • Unit: mock dependencies
  • Integration: real database
  • vendor/bin/phpunit

Commands:

  • module:enable Vendor_Module
  • setup:upgrade
  • setup:di:compile
  • setup:static-content:deploy
  • cache:clean

Cost:

  • Development: ~$1,600 (16 hours)
  • Monthly infra: ~$2
  • Annual maintenance: $2,400-$10,000