
Magento Module Development
- 64 installs
- 41 repo stars
- Updated March 13, 2026
- finsilabs/awesome-ecommerce-skills
Build custom Magento 2 modules using dependency injection, plugins, observers, service contracts, and declarative schema to extend core cleanly.
About
Covers the Magento 2 module skeleton, DI container, plugins/observers, repository pattern, custom REST/GraphQL endpoints, and db_schema.xml. A developer uses it to add functionality or override core behavior in a Magento store.
- Module skeleton, registration, and DI/service-contract patterns
- Plugin interceptors, observers, and declarative database schema
Magento Module Development by the numbers
- 64 all-time installs (skills.sh)
- Ranked #47 of 65 PHP & Laravel skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/finsilabs/awesome-ecommerce-skills --skill magento-module-developmentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 64 |
|---|---|
| repo stars | ★ 41 |
| Last updated | March 13, 2026 |
| Repository | finsilabs/awesome-ecommerce-skills ↗ |
What it does
Build custom Magento 2 modules using dependency injection, plugins, observers, service contracts, and declarative schema to extend core cleanly.
Files
Magento 2 Module Development
Overview
Build custom Magento 2 modules using the module architecture, dependency injection (DI), service contracts (interfaces), plugins (interceptors), observers, and the repository pattern. This skill covers the Magento 2 module skeleton, XML-based configuration, the Object Manager and DI container, custom REST/GraphQL API endpoints, and database schema management with db_schema.xml (declarative schema).
When to Use This Skill
- When building a custom module that adds new functionality to a Magento 2 store
- When extending or overriding core Magento behavior using plugins or preferences
- When creating custom REST API or GraphQL endpoints for headless integrations
- When adding custom database tables with declarative schema
- When implementing admin grids, forms, and system configuration
Core Instructions
1. Create the module skeleton
Every Magento 2 module lives in app/code/Vendor/Module and requires at minimum two files:
app/code/Acme/CustomModule/
├── etc/
│ └── module.xml
├── registration.php
├── Api/
│ └── CustomRepositoryInterface.php
├── Model/
│ ├── CustomRepository.php
│ └── ResourceModel/
├── Controller/
├── Block/
├── view/
│ ├── frontend/
│ └── adminhtml/
└── Setup/
└── Patch/
└── Data/ // registration.php
<?php
declare(strict_types=1);
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Acme_CustomModule',
__DIR__
); <!-- etc/module.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Acme_CustomModule" setup_version="1.0.0">
<sequence>
<module name="Magento_Catalog"/>
<module name="Magento_Sales"/>
</sequence>
</module>
</config>2. Define service contracts (interfaces) and implement them
Service contracts ensure your module provides a stable API that other modules and integrations can rely on:
// Api/Data/CustomEntityInterface.php
<?php
declare(strict_types=1);
namespace Acme\CustomModule\Api\Data;
interface CustomEntityInterface
{
const ENTITY_ID = 'entity_id';
const NAME = 'name';
const STATUS = 'status';
const CREATED_AT = 'created_at';
public function getEntityId(): ?int;
public function getName(): string;
public function setName(string $name): self;
public function getStatus(): string;
public function setStatus(string $status): self;
public function getCreatedAt(): ?string;
} // Api/CustomRepositoryInterface.php
<?php
declare(strict_types=1);
namespace Acme\CustomModule\Api;
use Acme\CustomModule\Api\Data\CustomEntityInterface;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Api\SearchResultsInterface;
use Magento\Framework\Exception\NoSuchEntityException;
interface CustomRepositoryInterface
{
/**
* @throws NoSuchEntityException
*/
public function getById(int $id): CustomEntityInterface;
public function save(CustomEntityInterface $entity): CustomEntityInterface;
public function delete(CustomEntityInterface $entity): bool;
public function getList(SearchCriteriaInterface $searchCriteria): SearchResultsInterface;
}3. Implement the Model, ResourceModel, and Repository
// Model/CustomEntity.php
<?php
declare(strict_types=1);
namespace Acme\CustomModule\Model;
use Acme\CustomModule\Api\Data\CustomEntityInterface;
use Magento\Framework\Model\AbstractModel;
class CustomEntity extends AbstractModel implements CustomEntityInterface
{
protected function _construct(): void
{
$this->_init(\Acme\CustomModule\Model\ResourceModel\CustomEntity::class);
}
public function getEntityId(): ?int
{
return $this->getData(self::ENTITY_ID) ? (int) $this->getData(self::ENTITY_ID) : null;
}
public function getName(): string
{
return (string) $this->getData(self::NAME);
}
public function setName(string $name): CustomEntityInterface
{
return $this->setData(self::NAME, $name);
}
public function getStatus(): string
{
return (string) $this->getData(self::STATUS);
}
public function setStatus(string $status): CustomEntityInterface
{
return $this->setData(self::STATUS, $status);
}
public function getCreatedAt(): ?string
{
return $this->getData(self::CREATED_AT);
}
} // Model/ResourceModel/CustomEntity.php
<?php
declare(strict_types=1);
namespace Acme\CustomModule\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
class CustomEntity extends AbstractDb
{
protected function _construct(): void
{
$this->_init('acme_custom_entity', 'entity_id');
}
} // Model/CustomRepository.php
<?php
declare(strict_types=1);
namespace Acme\CustomModule\Model;
use Acme\CustomModule\Api\CustomRepositoryInterface;
use Acme\CustomModule\Api\Data\CustomEntityInterface;
use Acme\CustomModule\Model\ResourceModel\CustomEntity as ResourceModel;
use Acme\CustomModule\Model\CustomEntityFactory;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Api\SearchResultsInterface;
use Magento\Framework\Api\SearchResultsInterfaceFactory;
use Magento\Framework\Exception\NoSuchEntityException;
class CustomRepository implements CustomRepositoryInterface
{
public function __construct(
private readonly ResourceModel $resourceModel,
private readonly CustomEntityFactory $entityFactory,
private readonly SearchResultsInterfaceFactory $searchResultsFactory,
private readonly \Acme\CustomModule\Model\ResourceModel\CustomEntity\CollectionFactory $collectionFactory
) {}
public function getById(int $id): CustomEntityInterface
{
$entity = $this->entityFactory->create();
$this->resourceModel->load($entity, $id);
if (!$entity->getEntityId()) {
throw new NoSuchEntityException(
__('Entity with ID "%1" does not exist.', $id)
);
}
return $entity;
}
public function save(CustomEntityInterface $entity): CustomEntityInterface
{
$this->resourceModel->save($entity);
return $entity;
}
public function delete(CustomEntityInterface $entity): bool
{
$this->resourceModel->delete($entity);
return true;
}
public function getList(SearchCriteriaInterface $searchCriteria): SearchResultsInterface
{
$collection = $this->collectionFactory->create();
foreach ($searchCriteria->getFilterGroups() as $filterGroup) {
foreach ($filterGroup->getFilters() as $filter) {
$collection->addFieldToFilter(
$filter->getField(),
[$filter->getConditionType() => $filter->getValue()]
);
}
}
$searchResults = $this->searchResultsFactory->create();
$searchResults->setSearchCriteria($searchCriteria);
$searchResults->setItems($collection->getItems());
$searchResults->setTotalCount($collection->getSize());
return $searchResults;
}
}4. Configure dependency injection with `di.xml`
<!-- etc/di.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<!-- Bind interfaces to implementations (preferences) -->
<preference for="Acme\CustomModule\Api\Data\CustomEntityInterface"
type="Acme\CustomModule\Model\CustomEntity"/>
<preference for="Acme\CustomModule\Api\CustomRepositoryInterface"
type="Acme\CustomModule\Model\CustomRepository"/>
<!-- Virtual type: reusable configured class without a new PHP file -->
<virtualType name="Acme\CustomModule\Model\ResourceModel\CustomEntity\Grid\Collection"
type="Magento\Framework\View\Element\UiComponent\DataProvider\SearchResult">
<arguments>
<argument name="mainTable" xsi:type="string">acme_custom_entity</argument>
<argument name="resourceModel"
xsi:type="string">Acme\CustomModule\Model\ResourceModel\CustomEntity</argument>
</arguments>
</virtualType>
<!-- Constructor argument injection -->
<type name="Acme\CustomModule\Model\SomeService">
<arguments>
<argument name="maxRetries" xsi:type="number">3</argument>
<argument name="logger" xsi:type="object">Psr\Log\LoggerInterface</argument>
</arguments>
</type>
</config>5. Create database tables with declarative schema
<!-- etc/db_schema.xml -->
<?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="acme_custom_entity" resource="default" engine="innodb"
comment="Acme Custom Entity Table">
<column xsi:type="int" name="entity_id" unsigned="true" nullable="false"
identity="true" comment="Entity ID"/>
<column xsi:type="varchar" name="name" nullable="false" length="255"
comment="Name"/>
<column xsi:type="varchar" name="status" nullable="false" length="20"
default="active" comment="Status"/>
<column xsi:type="decimal" name="amount" precision="12" scale="4"
nullable="true" comment="Amount"/>
<column xsi:type="timestamp" name="created_at" nullable="false"
default="CURRENT_TIMESTAMP" comment="Created At"/>
<column xsi:type="timestamp" name="updated_at" nullable="false"
default="CURRENT_TIMESTAMP" on_update="true" comment="Updated At"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="entity_id"/>
</constraint>
<index referenceId="ACME_CUSTOM_ENTITY_STATUS" indexType="btree">
<column name="status"/>
</index>
</table>
</schema>Generate the whitelist file after modifying db_schema.xml:
bin/magento setup:db-declaration:generate-whitelist --module-name=Acme_CustomModule6. Use plugins (interceptors) to modify core behavior
// Plugin/ProductPricePlugin.php
<?php
declare(strict_types=1);
namespace Acme\CustomModule\Plugin;
use Magento\Catalog\Model\Product;
class ProductPricePlugin
{
/**
* After-plugin: modify the return value of getPrice()
*/
public function afterGetPrice(Product $subject, float $result): float
{
// Example: apply a 10% surcharge for a specific attribute
if ($subject->getData('requires_special_handling')) {
return $result * 1.10;
}
return $result;
}
/**
* Before-plugin: modify input arguments
*/
public function beforeSetPrice(Product $subject, $price): array
{
// Ensure price is never negative
return [max(0, (float) $price)];
}
/**
* Around-plugin: wrap the original method (use sparingly)
*/
public function aroundGetName(Product $subject, callable $proceed): string
{
$name = $proceed();
$badge = $subject->getData('custom_badge');
return $badge ? "[{$badge}] {$name}" : $name;
}
}Register the plugin in di.xml:
<type name="Magento\Catalog\Model\Product">
<plugin name="acme_custom_price_plugin"
type="Acme\CustomModule\Plugin\ProductPricePlugin"
sortOrder="10"/>
</type>Examples
Custom REST API endpoint
// etc/webapi.xml
<?xml version="1.0"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<route url="/V1/acme/custom-entities" method="GET">
<service class="Acme\CustomModule\Api\CustomRepositoryInterface" method="getList"/>
<resources>
<resource ref="Magento_Catalog::catalog"/>
</resources>
</route>
<route url="/V1/acme/custom-entities/:id" method="GET">
<service class="Acme\CustomModule\Api\CustomRepositoryInterface" method="getById"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
<route url="/V1/acme/custom-entities" method="POST">
<service class="Acme\CustomModule\Api\CustomRepositoryInterface" method="save"/>
<resources>
<resource ref="Magento_Catalog::catalog"/>
</resources>
</route>
</routes>Observer for post-order events
// Observer/OrderPlaceAfterObserver.php
<?php
declare(strict_types=1);
namespace Acme\CustomModule\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Model\Order;
use Psr\Log\LoggerInterface;
class OrderPlaceAfterObserver implements ObserverInterface
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly \Acme\CustomModule\Model\ExternalSyncService $syncService
) {}
public function execute(Observer $observer): void
{
/** @var Order $order */
$order = $observer->getEvent()->getOrder();
try {
$this->syncService->pushOrder($order);
$this->logger->info(
sprintf('Order #%s synced to external system.', $order->getIncrementId())
);
} catch (\Exception $e) {
// Log but do not block order placement
$this->logger->error(
sprintf('Failed to sync order #%s: %s', $order->getIncrementId(), $e->getMessage())
);
}
}
}<!-- etc/events.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="sales_order_place_after">
<observer name="acme_order_sync" instance="Acme\CustomModule\Observer\OrderPlaceAfterObserver"/>
</event>
</config>Best Practices
- Always use service contracts -- inject interfaces (
CustomRepositoryInterface) not concrete classes; this enables compatibility with REST, GraphQL, and other modules - Prefer plugins over class rewrites -- plugins (interceptors) are composable and allow multiple modules to modify the same method; preferences (rewrites) cause conflicts
- Use `around` plugins sparingly -- they wrap the entire method call and prevent other plugins from executing if you forget to call
$proceed(); preferbefore/afterplugins - Declare module dependencies in `module.xml` -- the
<sequence>node ensures your module loads after its dependencies; missing sequences cause subtle load-order bugs - Use declarative schema (`db_schema.xml`) -- avoid legacy
InstallSchema/UpgradeSchemascripts; declarative schema is idempotent and supports rollback - Never use the Object Manager directly -- always use constructor dependency injection; direct
ObjectManager::getInstance()calls bypass DI configuration and break testability - Run `bin/magento setup:di:compile` after changes -- the DI compilation step generates interceptors and factories; missing compilation causes "class not found" errors in production mode
- Follow Magento coding standards -- run
vendor/bin/phpcs --standard=Magento2on your module before release
Common Pitfalls
| Problem | Solution |
|---|---|
| "Class does not exist" after adding a new class | Run bin/magento setup:di:compile and bin/magento cache:flush; check namespace matches directory path exactly |
| Plugin not executing | Verify the plugin is registered in the correct scope's di.xml (etc/frontend/di.xml for frontend, etc/di.xml for global) and the sortOrder doesn't conflict |
| Declarative schema changes not applying | Run bin/magento setup:upgrade and regenerate the whitelist with setup:db-declaration:generate-whitelist |
| Circular dependency injection error | Refactor one of the dependent classes to use a Proxy (\Acme\CustomModule\Model\SomeClass\Proxy) in di.xml to break the cycle |
| Observer throws exception and blocks checkout | Wrap observer logic in try/catch; observers should log errors but never throw exceptions that block critical flows |
| Factory class not found | Factories are auto-generated by DI compilation; run bin/magento setup:di:compile or check that the base class exists |
Related Skills
- @product-data-modeling
- @erp-integration
- @ecommerce-caching
- @pci-dss-compliance
- @ecommerce-seo
{
"context": "Tests whether the agent uses declarative schema (db_schema.xml) instead of legacy InstallSchema scripts, registers REST routes via webapi.xml pointing to service contract interfaces, implements NoSuchEntityException in the repository getById method, documents required CLI commands including the whitelist generation command, and wires up DI preferences correctly.",
"type": "weighted_checklist",
"checklist": [
{
"name": "Declarative schema file",
"max_score": 10,
"description": "etc/db_schema.xml exists and defines the FAQ table using the Magento declarative schema XSD — no InstallSchema.php or UpgradeSchema.php files are present"
},
{
"name": "No legacy schema scripts",
"max_score": 8,
"description": "The module does NOT contain Setup/InstallSchema.php or Setup/UpgradeSchema.php files"
},
{
"name": "REST routes in webapi.xml",
"max_score": 10,
"description": "etc/webapi.xml exists and defines at least two <route> elements (GET list and GET by ID) mapping to service contract interface methods"
},
{
"name": "Service contract used in webapi.xml",
"max_score": 8,
"description": "The <service class> attribute in webapi.xml references an interface (e.g., ends in 'Interface'), not a concrete class"
},
{
"name": "NoSuchEntityException in getById",
"max_score": 8,
"description": "The repository's getById() method throws Magento\\Framework\\Exception\\NoSuchEntityException when no entity with the given ID is found"
},
{
"name": "DI preferences bound",
"max_score": 8,
"description": "etc/di.xml contains <preference> elements for both the data interface and the repository interface"
},
{
"name": "setup:upgrade in deployment guide",
"max_score": 6,
"description": "deployment-guide.md includes bin/magento setup:upgrade"
},
{
"name": "setup:di:compile in deployment guide",
"max_score": 7,
"description": "deployment-guide.md includes bin/magento setup:di:compile"
},
{
"name": "generate-whitelist in deployment guide",
"max_score": 8,
"description": "deployment-guide.md includes the command bin/magento setup:db-declaration:generate-whitelist"
},
{
"name": "declare(strict_types=1) in PHP files",
"max_score": 6,
"description": "Every PHP file in the module begins with declare(strict_types=1) after <?php"
},
{
"name": "No Object Manager direct usage",
"max_score": 6,
"description": "No PHP file contains ObjectManager::getInstance() — all dependencies injected via constructor"
},
{
"name": "Repository interface methods",
"max_score": 7,
"description": "The repository interface defines at minimum getById(), save(), delete(), and getList() method signatures"
},
{
"name": "Constructor injection in Repository",
"max_score": 8,
"description": "The Repository implementation receives ResourceModel, Factory, and SearchResultsInterfaceFactory as constructor parameters"
}
]
}
Product FAQ REST API Module
Problem/Feature Description
A headless commerce team is building a React storefront that needs to display frequently asked questions (FAQs) associated with individual products. The team needs a new Magento 2 module called Acme_ProductFaq that stores FAQ entries in a dedicated database table and exposes them through the Magento REST API. Each FAQ entry should have an auto-increment ID, a product ID it belongs to, a question text, an answer text, and timestamps for creation and last update.
The REST API must allow: listing all FAQs for a given product (public endpoint, no auth required), retrieving a single FAQ by its ID, and creating a new FAQ entry. The backend team insists the database schema is defined in a way that supports clean upgrades and rollbacks without manual SQL scripts. The module must also follow Magento's recommended approach for exposing entity operations — other backend modules should be able to depend on the FAQ data layer without importing concrete classes.
Output Specification
Produce all source files for Acme_ProductFaq under app/code/Acme/ProductFaq/. Include:
- All module skeleton and configuration files
- Service contract interfaces (data interface and repository interface)
- Model, ResourceModel, Collection, and Repository implementations
- Database schema definition file
- REST API route definitions file
- Dependency injection configuration file
Create a deployment-guide.md file at the root of your working directory that lists in order all bin/magento CLI commands a developer must run after deploying this module to a live Magento 2 instance to fully activate the module, create its tables, and make the REST API available.
{
"context": "Tests whether the agent creates a properly structured Magento 2 module with correct module skeleton files, service contracts in the Api/ directory, Model/ResourceModel layers, DI configuration with interface-to-implementation bindings, and declarative database schema — following Magento 2 conventions throughout.",
"type": "weighted_checklist",
"checklist": [
{
"name": "Module directory location",
"max_score": 4,
"description": "Module files are placed under app/code/<Vendor>/<Module>/ (not in vendor/ or any other location)"
},
{
"name": "registration.php uses ComponentRegistrar",
"max_score": 7,
"description": "registration.php calls ComponentRegistrar::register() with ComponentRegistrar::MODULE, a 'Vendor_Module' string, and __DIR__"
},
{
"name": "module.xml has sequence node",
"max_score": 7,
"description": "etc/module.xml contains a <sequence> element listing at least one dependent module (e.g., Magento_Customer or similar)"
},
{
"name": "Data interface in Api/Data/",
"max_score": 7,
"description": "An interface file exists under Api/Data/ defining getter/setter methods for the entity's fields"
},
{
"name": "Repository interface in Api/",
"max_score": 7,
"description": "A repository interface exists under Api/ with at minimum getById(), save(), delete(), and getList() method signatures"
},
{
"name": "Model extends AbstractModel",
"max_score": 7,
"description": "The Model class extends Magento\\Framework\\Model\\AbstractModel and implements the data interface"
},
{
"name": "Model _construct calls _init",
"max_score": 7,
"description": "The Model class has a _construct() method that calls $this->_init() passing the ResourceModel class name"
},
{
"name": "ResourceModel extends AbstractDb",
"max_score": 7,
"description": "The ResourceModel class extends Magento\\Framework\\Model\\ResourceModel\\Db\\AbstractDb with _construct() calling _init() with table name and primary key"
},
{
"name": "DI preferences in di.xml",
"max_score": 8,
"description": "etc/di.xml contains <preference> elements binding both the data interface and repository interface to their concrete implementations"
},
{
"name": "Declarative schema file",
"max_score": 8,
"description": "etc/db_schema.xml exists and defines the custom table with columns, a primary key constraint, and uses the Magento declarative schema XSD"
},
{
"name": "No Object Manager direct usage",
"max_score": 8,
"description": "No PHP file contains ObjectManager::getInstance() or direct instantiation via the Object Manager; all dependencies are injected via constructor"
},
{
"name": "declare(strict_types=1) in PHP files",
"max_score": 8,
"description": "Every PHP file begins with declare(strict_types=1) immediately after the opening <?php tag"
},
{
"name": "setup:di:compile in setup notes",
"max_score": 5,
"description": "setup-notes.md includes the command bin/magento setup:di:compile"
},
{
"name": "generate-whitelist in setup notes",
"max_score": 5,
"description": "setup-notes.md includes the command bin/magento setup:db-declaration:generate-whitelist"
},
{
"name": "Constructor DI throughout",
"max_score": 5,
"description": "Repository class receives ResourceModel, Factory, and SearchResultsInterfaceFactory via constructor parameters, not created inline"
}
]
}
Loyalty Program Membership Module
Problem/Feature Description
An outdoor gear retailer is launching a tiered loyalty program for their Magento 2 store. The program needs to track membership records — each member has a unique ID, a tier name (e.g., "Bronze", "Silver", "Gold"), a points balance, and a join date. The development team needs a new custom Magento 2 module that stores these records in a dedicated database table and provides a clean, reusable PHP API for other modules and integrations to create, retrieve, and list memberships.
The team already has a Magento 2 codebase but no loyalty module exists yet. The module should be structured so that future modules (such as a checkout module that awards points on purchase) can depend on it cleanly, without tight coupling to implementation details. The module will eventually depend on the Magento_Customer module, so the load order must be arranged accordingly.
Output Specification
Produce all the PHP and XML source files for the module under app/code/Acme/Loyalty/. The directory tree should reflect a complete module scaffold with correct namespace usage throughout.
Specifically, provide:
- All configuration XML files needed at the module level
- All PHP interface definitions for data access
- All PHP implementation classes (model, resource model, repository)
- The dependency injection configuration file
- The database schema definition file
Also create a setup-notes.md file at the root of your working directory that lists the bin/magento CLI commands a developer must run after deploying this module to a Magento 2 instance (to register the module, create tables, and prepare the DI container).
{
"context": "Tests whether the agent correctly uses Magento 2 plugins over class rewrites, chooses appropriate plugin types (preferring before/after over around), registers the plugin in the correct frontend scope, implements observers with try/catch to avoid blocking critical flows, and documents the design rationale.",
"type": "weighted_checklist",
"checklist": [
{
"name": "Plugin class, not preference",
"max_score": 10,
"description": "The name modification is implemented as a Plugin class (with before/after/aroundGetName or similar method), NOT as a <preference> rewrite of the Product model"
},
{
"name": "No around plugin for name",
"max_score": 10,
"description": "The plugin for modifying the product name does NOT use an around method (aroundGetName) — uses afterGetName or beforeGetName instead"
},
{
"name": "Plugin registered in frontend scope",
"max_score": 10,
"description": "The product name plugin is registered in etc/frontend/di.xml (not in etc/di.xml global scope)"
},
{
"name": "Plugin registered with type and name",
"max_score": 8,
"description": "etc/frontend/di.xml contains a <type> element with a <plugin> child that has name, type (class), and sortOrder attributes"
},
{
"name": "Observer implements ObserverInterface",
"max_score": 8,
"description": "The observer class implements Magento\\Framework\\Event\\ObserverInterface and has an execute(Observer $observer): void method"
},
{
"name": "Observer has try/catch",
"max_score": 10,
"description": "The observer's execute() method wraps its logic in a try/catch block and does NOT re-throw or let exceptions propagate"
},
{
"name": "Observer logs error, not throws",
"max_score": 8,
"description": "In the catch block of the observer, the exception is logged (e.g., via LoggerInterface) rather than thrown or silently swallowed without any action"
},
{
"name": "Observer registered in events.xml",
"max_score": 8,
"description": "etc/events.xml (or etc/frontend/events.xml) exists and contains an <event> element with an <observer> child referencing the observer class"
},
{
"name": "declare(strict_types=1) in PHP files",
"max_score": 6,
"description": "Every PHP file in the module begins with declare(strict_types=1) after <?php"
},
{
"name": "No Object Manager direct usage",
"max_score": 6,
"description": "No PHP file contains ObjectManager::getInstance() — all dependencies injected via constructor"
},
{
"name": "Design notes: plugin preference",
"max_score": 8,
"description": "design-notes.md explains that plugins are composable and avoid conflicts compared to preferences/rewrites"
},
{
"name": "Design notes: plugin type choice",
"max_score": 8,
"description": "design-notes.md identifies the chosen plugin type (before/after) and explicitly states that around plugins are avoided or used sparingly because they can prevent other plugins from running"
}
]
}
Product Availability Badge and View Tracking Module
Problem/Feature Description
A fashion retailer's merchandising team wants two small but important enhancements to their Magento 2 store. First, products that are tagged with a custom attribute is_exclusive should display a "[Exclusive]" prefix in their name wherever the name appears on the storefront — on product listing pages, detail pages, and in search results. The team explicitly does not want to change the stored product name in the database; they only want the display to be modified at runtime.
Second, the analytics team wants to record a log entry whenever a customer views a product detail page. The logging must not interfere with page rendering under any circumstances — if the logging step fails, the storefront page should still load normally. The observer should log both the product SKU and the event outcome. All necessary configuration files to wire up this event listener must be included.
The module should be named Acme_Merchandising. Implement only the storefront (frontend) behavior for the name modification; the admin panel should be unaffected.
Output Specification
Produce all source files for the Acme_Merchandising module under app/code/Acme/Merchandising/. Include all module skeleton files, all PHP implementation classes, and all XML configuration files needed to wire up the two behaviors.
Create a design-notes.md file at the root of your working directory explaining: (1) the technical approach chosen for the product name modification and why it was preferred over other options, (2) which specific method type within that approach was selected and why, and (3) in which scope the name modification is registered and why.
{
"name": "finsi/magento-module-development",
"version": "0.1.0",
"summary": "Custom Magento 2 modules with dependency injection and service contracts",
"skills": {
"magento-module-development": {
"path": "SKILL.md"
}
}
}