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?
- Feature gap: Magento core + marketplace extensions don't cover 100% of business requirements; ~30% of Magento implementations require at least one custom module
- Business process automation: Custom modules encode unique business rules (loyalty tiers, custom pricing, specialized checkout flows)
- System integration: Connecting Magento to ERP (SAP, NetSuite), WMS (ShipStation), or CRM (Salesforce) requires custom API modules
- Competitive differentiation: Custom features (personalization engines, configurators) create unique shopping experiences
- 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:statusto verify module state - Run full test suite (unit + integration)
- Run
php bin/magento setup:dry-runto preview schema changes - Verify
composer.jsondependencies 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
Build a complete custom module that allows customers to save products to a personal list.
Quiz
1. What file registers a Magento module?
2. What does module.xml define?
3. What is the purpose of di.xml?
4. How to enable a custom module?
5. Which pattern is safest for modifying existing class behavior without breaking upgrades?
6. What command should you run before deploying a module with schema changes?
Flashcards
Question
What files register a module?
Click to reveal answer
Answer
registration.php and module.xml
Question
What does di.xml configure?
Click to reveal answer
Answer
Dependency injection: preferences, arguments, plugins
Question
What is a resource model?
Click to reveal answer
Answer
Handles database operations for a model
Question
What is a collection?
Click to reveal answer
Answer
Group of models with filtering and sorting capabilities
Question
How to run module tests?
Click to reveal answer
Answer
vendor/bin/phpunit with appropriate config file
Question
When should you use a Plugin over a Preference?
Click to reveal answer
Answer
Plugin is upgrade-safe and chains around methods; Preference replaces the class entirely
Question
What is the recommended schema approach for new modules?
Click to reveal answer
Answer
Declarative schema (db_schema.xml) for upgrade-safety
Question
What is the typical development cost for a simple custom module?
Click to reveal answer
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