magento-module-development について
Magento 2 開発者向けのモジュール開発ガイドです。モジュール構成、依存性注入、サービス契約、プラグイン、オブザーバー、宣言型データベーススキーマを扱います。カスタム REST・GraphQL API、管理画面、開発時の一般的な問題も取り上げます。
主な機能
- 01モジュール構成と登録ファイルの作成
- 02サービス契約とリポジトリの利用
- 03依存性注入・プラグイン・オブザーバーの設定
- 04宣言型スキーマによるデータベース管理
- 05カスタム REST・GraphQL API の作成
活用シーン
- 01ストアに独自機能を追加
- 02Magento コアの動作を拡張・変更
- 03ヘッドレス連携用 API エンドポイントの提供
- 04データベーステーブルと管理画面の追加
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ finsilabs/ awesome-ecommerce-skills --skill magento-module-developmentインストール後、Claude Code で/magento-module-developmentを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
tile.jsonevals/declarative-schema-and-rest-api-endpoint/criteria.jsontask.mdevals/module-skeleton-service-contracts-and-di/criteria.jsontask.mdevals/plugins-observers-and-event-handling/criteria.jsontask.md詳細
Magento 2 モジュール開発
概要
モジュールアーキテクチャ、依存性注入(DI)、サービスコントラクト(インターフェース)、プラグイン(インターセプター)、オブザーバー、リポジトリパターンを使用して、カスタム Magento 2 モジュールを構築します。このスキルでは、Magento 2 モジュールのスケルトン、XML ベースの設定、Object Manager と DI コンテナー、カスタム REST/GraphQL API エンドポイント、db_schema.xml(宣言型スキーマ)によるデータベーススキーマ管理を扱います。
このスキルを使用する場面
- Magento 2 ストアに新しい機能を追加するカスタムモジュールを構築するとき
- プラグインやプリファレンスを使用して、Magento のコア動作を拡張またはオーバーライドするとき
- ヘッドレス連携用のカスタム REST API または GraphQL エンドポイントを作成するとき
- 宣言型スキーマを使用してカスタムデータベーステーブルを追加するとき
- 管理画面のグリッド、フォーム、システム設定を実装するとき
コア手順
-
モジュールのスケルトンを作成する
Magento 2 の各モジュールは
app/code/Vendor/Moduleに配置し、最低限、次の 2 つのファイルが必要です。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__ );<?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> -
サービスコントラクト(インターフェース)を定義して実装する
サービスコントラクトにより、他のモジュールや連携先が利用できる安定した API をモジュールが提供できます。
// 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; } -
Model、ResourceModel、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; } } -
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"> <preference for="Acme\CustomModule\Api\Data\CustomEntityInterface" type="Acme\CustomModule\Model\CustomEntity"/> <preference for="Acme\CustomModule\Api\CustomRepositoryInterface" type="Acme\CustomModule\Model\CustomRepository"/> <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> <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> -
宣言型スキーマでデータベーステーブルを作成する
<?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>db_schema.xmlを変更した後、ホワイトリストファイルを生成します。bin/magento setup:db-declaration:generate-whitelist --module-name=Acme_CustomModule -
プラグイン(インターセプター)を使用してコアの動作を変更する
// 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; } }di.xmlにプラグインを登録します。<type name="Magento\Catalog\Model\Product"> <plugin name="acme_custom_price_plugin" type="Acme\CustomModule\Plugin\ProductPricePlugin" sortOrder="10"/> </type>
例
カスタム REST API エンドポイント
// 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/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())
);
}
}
}
<?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>
ベストプラクティス
- 常にサービスコントラクトを使用する — 具象クラスではなく、インターフェース(
CustomRepositoryInterface)をインジェクトしてください。これにより、REST、GraphQL、その他のモジュールとの互換性を確保できます。 - クラスの書き換えよりプラグインを優先する — プラグイン(インターセプター)は組み合わせて使用でき、複数のモジュールで同じメソッドを変更できます。プリファレンス(書き換え)は競合の原因になります。
aroundプラグインは必要最小限にする — メソッド呼び出し全体をラップするため、$proceed()の呼び出しを忘れると、他のプラグインが実行されなくなります。before/afterプラグインを優先してください。module.xmlでモジュールの依存関係を宣言する —<sequence>ノードにより、依存先の後にモジュールが読み込まれるようにします。sequenceの指定がないと、読み込み順序に関する見つけにくい不具合が発生します。- 宣言型スキーマ(
db_schema.xml)を使用する — 従来のInstallSchema/UpgradeSchemaスクリプトは避けてください。宣言型スキーマは冪等性があり、ロールバックにも対応しています。 - Object Manager を直接使用しない — 常にコンストラクターによる依存性注入を使用してください。
ObjectManager::getInstance()を直接呼び出すと、DI 設定が迂回され、テスト容易性が損なわれます。 - 変更後に
bin/magento setup:di:compileを実行する — DI コンパイルの手順では、インターセプターとファクトリーが生成されます。コンパイルが不足すると、本番モードで「クラスが見つかりません」というエラーが発生します。 - Magento のコーディング規約に従う — リリース前に、モジュールに対して
vendor/bin/phpcs --standard=Magento2を実行してください。
よくある問題
| 問題 | 解決策 |
|---|---|
| 新しいクラスを追加した後に「Class does not exist」と表示される | bin/magento setup:di:compile と bin/magento cache:flush を実行し、名前空間がディレクトリパスと完全に一致していることを確認します |
| プラグインが実行されない | プラグインが正しいスコープの di.xml に登録されていること(フロントエンドの場合は etc/frontend/di.xml、グローバルの場合は etc/di.xml)と、sortOrder が競合していないことを確認します |
| 宣言的スキーマの変更が適用されない | bin/magento setup:upgrade を実行し、setup:db-declaration:generate-whitelist でホワイトリストを再生成します |
| 循環依存のインジェクションエラーが発生する | 依存するクラスのいずれかをリファクタリングし、di.xml で Proxy(\Acme\CustomModule\Model\SomeClass\Proxy)を使用して循環を解消します |
| オブザーバーが例外をスローし、チェックアウトを妨げる | オブザーバーのロジックを try/catch で囲みます。オブザーバーはエラーをログに記録する必要がありますが、重要な処理を妨げる例外をスローしてはいけません |
| ファクトリークラスが見つからない | ファクトリーは DI コンパイル時に自動生成されます。bin/magento setup:di:compile を実行するか、ベースクラスが存在することを確認します |
関連スキル
- @product-data-modeling
- @erp-integration
- @ecommerce-caching
- @pci-dss-compliance
- @ecommerce-seo