Un modulo in Magento 2 è un’unità indipendente di funzionalità che può aggiungere o modificare comportamenti del sistema: nuove rotte, pagine, API, comandi CLI, cron, osservatori di eventi, componenti per l’area amministrativa e molto altro. In questa guida costruiremo un modulo “base” e poi vedremo i punti chiave per renderlo professionale: registrazione, dipendenze, configurazione DI, routing, layout e template, database (schema dichiarativo), data patch, area admin con ACL e menu, Web API, plugin e preferenze, eventi e observer, testing e packaging.
Prerequisiti e convenzioni
- Magento 2 installato in ambiente di sviluppo (consigliato Docker o VM dedicata).
- Accesso alla shell nella root del progetto Magento.
- Conoscenza base di PHP, Composer, XML e struttura MVC.
- Namespace del vendor e nome modulo: nell’esempio useremo Acme_Demo.
Magento 2 carica i moduli in due posizioni principali:
- app/code: sviluppo “in-place” durante la fase di sviluppo.
- vendor: distribuzione tramite Composer (consigliata per ambienti reali).
Per semplicità partiremo da app/code, poi vedremo come impacchettare via Composer.
1) Creare la struttura del modulo
Creiamo la cartella del modulo:
mkdir -p app/code/Acme/Demo
Struttura minima consigliata:
app/code/Acme/Demo/
├── registration.php
└── etc/
└── module.xml
registration.php
Questo file registra il modulo presso il componente di bootstrap di Magento.
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Acme_Demo',
__DIR__
);
etc/module.xml
Dichiara nome e versione del modulo, oltre a eventuali dipendenze da altri moduli.
<?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_Demo" setup_version="1.0.0">
<sequence>
<module name="Magento_Backend"/>
</sequence>
</module>
</config>
Nota: nelle versioni moderne di Magento, per molti casi setup_version è meno centrale grazie allo schema dichiarativo e alle patch,
ma resta utile come metadato.
2) Abilitare e verificare il modulo
Dalla root di Magento esegui:
php bin/magento module:status
php bin/magento module:enable Acme_Demo
php bin/magento setup:upgrade
php bin/magento cache:flush
Se tutto è corretto, module:status mostrerà Acme_Demo tra i moduli abilitati.
3) Aggiungere una pagina frontend: route, controller, layout e template
Creiamo una semplice pagina raggiungibile via URL, ad esempio /demo/index/index.
Routing: etc/frontend/routes.xml
Crea la directory e il file:
mkdir -p app/code/Acme/Demo/etc/frontend
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
<router id="standard">
<route id="demo" frontName="demo">
<module name="Acme_Demo"/>
</route>
</router>
</config>
Controller: Controller/Index/Index.php
Struttura delle cartelle:
mkdir -p app/code/Acme/Demo/Controller/Index
Controller minimalista che restituisce una pagina standard (Page Result):
<?php
namespace Acme\Demo\Controller\Index;
use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\View\Result\PageFactory;
class Index extends Action
{
private PageFactory $pageFactory;
public function __construct(Context $context, PageFactory $pageFactory)
{
parent::__construct($context);
$this->pageFactory = $pageFactory;
}
public function execute()
{
return $this->pageFactory->create();
}
}
Layout: view/frontend/layout/demo_index_index.xml
Questo file associa contenuti alla pagina generata dal controller.
mkdir -p app/code/Acme/Demo/view/frontend/layout
mkdir -p app/code/Acme/Demo/view/frontend/templates
<?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="content">
<block class="Magento\Framework\View\Element\Template"
name="acme.demo.hello"
template="Acme_Demo::hello.phtml"/>
</referenceContainer>
</body>
</page>
Template: view/frontend/templates/hello.phtml
<?php
/** @var \Magento\Framework\View\Element\Template $block */
?>
<h2>Hello da Acme_Demo</h2>
<p>Se vedi questo contenuto, routing, controller e layout funzionano.</p>
A questo punto prova a visitare: /demo/index/index sul tuo ambiente.
4) Dependency Injection: di.xml e una classe “service”
Magento 2 usa un container di Dependency Injection (DI). Le regole vengono dichiarate in XML e la risoluzione avviene in runtime. Creiamo una classe di servizio e iniettiamola nel controller.
Creare un service: Model/Greeting.php
mkdir -p app/code/Acme/Demo/Model
<?php
namespace Acme\Demo\Model;
class Greeting
{
public function getMessage(): string
{
return 'Benvenuto nel modulo Acme_Demo';
}
}
Usare il service nel controller
<?php
namespace Acme\Demo\Controller\Index;
use Acme\Demo\Model\Greeting;
use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\View\Result\PageFactory;
class Index extends Action
{
private PageFactory $pageFactory;
private Greeting $greeting;
public function __construct(Context $context, PageFactory $pageFactory, Greeting $greeting)
{
parent::__construct($context);
$this->pageFactory = $pageFactory;
$this->greeting = $greeting;
}
public function execute()
{
$page = $this->pageFactory->create();
$page->getConfig()->getTitle()->set($this->greeting->getMessage());
return $page;
}
}
In questo caso non serve di.xml perché Magento è in grado di istanziare Greeting automaticamente.
di.xml diventa importante quando vuoi dichiarare preferenze, plugin, virtual types, argomenti specifici, o quando
inietti interfacce che devono essere mappate a implementazioni concrete.
Esempio di preferenza: etc/di.xml
Supponiamo di definire un’interfaccia e scegliere l’implementazione tramite DI.
mkdir -p app/code/Acme/Demo/etc
<?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\Demo\Api\GreetingInterface" type="Acme\Demo\Model\Greeting"/>
</config>
Ricorda: le preferenze sono potenti ma possono creare conflitti con altri moduli. Se devi modificare il comportamento di classi core, spesso è meglio un plugin (interceptor) o un observer, a seconda del caso d’uso.
5) Layout e Block: quando non usare Template “puro”
Per logica più complessa, è preferibile usare un Block (classe PHP) che fornisce dati al template. Creiamo un block dedicato e modifichiamo il layout per usarlo.
Block/Hello.php
mkdir -p app/code/Acme/Demo/Block
<?php
namespace Acme\Demo\Block;
use Magento\Framework\View\Element\Template;
class Hello extends Template
{
public function getHeadline(): string
{
return 'Pagina demo con Block dedicato';
}
}
Aggiornare il layout
<?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="content">
<block class="Acme\Demo\Block\Hello"
name="acme.demo.hello"
template="Acme_Demo::hello.phtml"/>
</referenceContainer>
</body>
</page>
Aggiornare il template per usare il block
<?php
/** @var \Acme\Demo\Block\Hello $block */
?>
<h2><?= $block->escapeHtml($block->getHeadline()) ?></h2>
<p>Usa sempre metodi di escaping (escapeHtml, escapeUrl, escapeJs) per contenuti dinamici.</p>
6) Configurazione: system.xml e config.xml
Magento consente impostazioni configurabili via backend (Stores > Configuration) e accessibili in codice. Qui mostriamo la parte più comune: aggiungere un campo di configurazione e leggerlo.
Definire i default: etc/config.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Config/etc/config.xsd">
<default>
<acme_demo>
<general>
<enabled>1</enabled>
</general>
</acme_demo>
</default>
</config>
Creare la UI admin: etc/adminhtml/system.xml
mkdir -p app/code/Acme/Demo/etc/adminhtml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
<system>
<section id="acme_demo" translate="label" sortOrder="900" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Acme Demo</label>
<tab>general</tab>
<resource>Acme_Demo::config</resource>
<group id="general" translate="label" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Generale</label>
<field id="enabled" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Abilita modulo</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
</field>
</group>
</section>
</system>
</config>
Leggere la configurazione in codice
Un approccio pulito è creare un “config provider” che incapsula le chiavi.
<?php
namespace Acme\Demo\Model;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
class Config
{
private const XML_PATH_ENABLED = 'acme_demo/general/enabled';
public function __construct(private ScopeConfigInterface $scopeConfig) {}
public function isEnabled(?int $storeId = null): bool
{
return $this->scopeConfig->isSetFlag(self::XML_PATH_ENABLED, ScopeInterface::SCOPE_STORE, $storeId);
}
}
7) Database: schema dichiarativo e Data Patch
Magento 2 supporta lo schema dichiarativo tramite etc/db_schema.xml (preferibile ai vecchi script InstallSchema/UpgradeSchema).
Per popolare dati iniziali o migrare dati, si usano le Patch (DataPatchInterface / SchemaPatchInterface).
Creare una tabella: 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_demo_item" resource="default" engine="innodb" comment="Acme Demo Items">
<column xsi:type="int" name="entity_id" unsigned="true" nullable="false" identity="true" comment="Entity ID"/>
<column xsi:type="varchar" name="title" nullable="false" length="255" comment="Title"/>
<column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="entity_id"/>
</constraint>
<index referenceId="ACME_DEMO_ITEM_TITLE" indexType="btree">
<column name="title"/>
</index>
</table>
</schema>
Dopo aver aggiunto lo schema, esegui:
php bin/magento setup:upgrade
Inserire dati iniziali: Setup/Patch/Data/SeedItems.php
mkdir -p app/code/Acme/Demo/Setup/Patch/Data
<?php
namespace Acme\Demo\Setup\Patch\Data;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
class SeedItems implements DataPatchInterface
{
public function __construct(private ModuleDataSetupInterface $moduleDataSetup) {}
public function apply()
{
$this->moduleDataSetup->getConnection()->startSetup();
$this->moduleDataSetup->getConnection()->insertMultiple(
$this->moduleDataSetup->getTable('acme_demo_item'),
[
['title' => 'Primo elemento'],
['title' => 'Secondo elemento'],
]
);
$this->moduleDataSetup->getConnection()->endSetup();
return $this;
}
public static function getDependencies(): array
{
return [];
}
public function getAliases(): array
{
return [];
}
}
Le patch vengono eseguite durante setup:upgrade e vengono registrate in tabella per evitare esecuzioni ripetute.
8) Adminhtml: ACL, menu e una pagina in backend
Per aggiungere funzionalità in admin, servono in genere:
- ACL (risorse di permesso) in
etc/acl.xml - Menu in
etc/adminhtml/menu.xml - Route admin in
etc/adminhtml/routes.xml - Controller admin in
Controller/Adminhtml - Layout admin in
view/adminhtml/layout
ACL: etc/acl.xml
<?xml version="1.0"?>
<acl xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
<resources>
<resource id="Magento_Backend::admin">
<resource id="Acme_Demo::root" title="Acme Demo" sortOrder="10">
<resource id="Acme_Demo::config" title="Config" sortOrder="10"/>
<resource id="Acme_Demo::items" title="Items" sortOrder="20"/>
</resource>
</resource>
</resources>
</acl>
Menu: etc/adminhtml/menu.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Backend:etc/menu.xsd">
<menu>
<add id="Acme_Demo::root"
title="Acme Demo"
module="Acme_Demo"
sortOrder="90"
resource="Acme_Demo::root"/>
<add id="Acme_Demo::items"
title="Items"
module="Acme_Demo"
sortOrder="10"
parent="Acme_Demo::root"
action="acme_demo/items/index"
resource="Acme_Demo::items"/>
</menu>
</config>
Route admin: etc/adminhtml/routes.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
<router id="admin">
<route id="acme_demo" frontName="acme_demo">
<module name="Acme_Demo"/>
</route>
</router>
</config>
Controller admin: Controller/Adminhtml/Items/Index.php
mkdir -p app/code/Acme/Demo/Controller/Adminhtml/Items
<?php
namespace Acme\Demo\Controller\Adminhtml\Items;
use Magento\Backend\App\Action;
use Magento\Framework\View\Result\PageFactory;
class Index extends Action
{
public const ADMIN_RESOURCE = 'Acme_Demo::items';
public function __construct(Action\Context $context, private PageFactory $pageFactory)
{
parent::__construct($context);
}
public function execute()
{
$page = $this->pageFactory->create();
$page->setActiveMenu('Acme_Demo::items');
$page->getConfig()->getTitle()->prepend(__('Acme Demo Items'));
return $page;
}
}
Layout admin: view/adminhtml/layout/acme_demo_items_index.xml
mkdir -p app/code/Acme/Demo/view/adminhtml/layout
mkdir -p app/code/Acme/Demo/view/adminhtml/templates
<?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="content">
<block class="Magento\Backend\Block\Template"
name="acme.demo.items"
template="Acme_Demo::items.phtml"/>
</referenceContainer>
</body>
</page>
Template admin: view/adminhtml/templates/items.phtml
<h2>Area Admin - Items</h2>
<p>Qui potresti integrare una UI Component grid (listing) e form (edit) per gestire i record.</p>
Per vedere il menu e la pagina admin, effettua logout/login in backend e svuota cache se necessario.
9) Plugin e Observer: estendere senza “toccare” il core
Quando vuoi modificare il comportamento di una classe esistente, hai due principali strategie:
- Plugin (interceptor): esegue codice before, around o after un metodo specifico.
- Observer: risponde a un evento Magento (event-driven).
Esempio Plugin: etc/di.xml
Intercettiamo, per esempio, un metodo di una classe (qui solo dimostrativo).
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Catalog\Model\Product">
<plugin name="acme_demo_product_plugin" type="Acme\Demo\Plugin\ProductPlugin"/>
</type>
</config>
<?php
namespace Acme\Demo\Plugin;
use Magento\Catalog\Model\Product;
class ProductPlugin
{
public function afterGetName(Product $subject, string $result): string
{
return $result . ' (demo)';
}
}
Avvertenza: un plugin che modifica getName() di Product avrebbe impatti ovunque. In produzione,
scegli target più specifici e valuta performance e compatibilità.
Esempio Observer: etc/frontend/events.xml
Registriamo un observer su un evento (esempio comune: aggiunta prodotto al carrello).
<?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="checkout_cart_product_add_after">
<observer name="acme_demo_cart_add_after" instance="Acme\Demo\Observer\CartAddAfter"/>
</event>
</config>
<?php
namespace Acme\Demo\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Psr\Log\LoggerInterface;
class CartAddAfter implements ObserverInterface
{
public function __construct(private LoggerInterface $logger) {}
public function execute(Observer $observer)
{
$product = $observer->getEvent()->getProduct();
$this->logger->info('Acme_Demo: aggiunto al carrello SKU ' . $product->getSku());
}
}
10) Web API: esporre un endpoint REST
Magento permette di esporre servizi REST/SOAP definendo interfacce service contract e configurando etc/webapi.xml.
Approccio consigliato: definire un’interfaccia in Api e un’implementazione in Model.
Service contract: Api/PingInterface.php
mkdir -p app/code/Acme/Demo/Api
<?php
namespace Acme\Demo\Api;
interface PingInterface
{
/**
* @return string
*/
public function ping(): string;
}
Implementazione: Model/Ping.php
<?php
namespace Acme\Demo\Model;
use Acme\Demo\Api\PingInterface;
class Ping implements PingInterface
{
public function ping(): string
{
return 'pong';
}
}
Preferenza DI: 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">
<preference for="Acme\Demo\Api\PingInterface" type="Acme\Demo\Model\Ping"/>
</config>
Definizione 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-demo/ping" method="GET">
<service class="Acme\Demo\Api\PingInterface" method="ping"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
</routes>
Ora puoi testare l’endpoint (base URL variabile): GET /rest/V1/acme-demo/ping.
11) Qualità del codice: logging, eccezioni, performance e sicurezza
- Escaping output: in template usa i metodi di escape del block per prevenire XSS.
- Iniezione dipendenze: evita
ObjectManager::getnel codice applicativo. - Plugin mirati: evita plugin su metodi ad alta frequenza (es. getter usati ovunque) se non necessario.
- Cache: valuta Full Page Cache e cache per blocchi; non invalidare cache inutilmente.
- ACL: in admin assegna sempre risorse corrette, non usare permessi eccessivi.
- Validazione input: per controller, form e API valida parametri e gestisci errori con eccezioni significative.
12) Packaging via Composer: rendere il modulo distribuibile
Per distribuire un modulo, conviene pubblicarlo come pacchetto Composer.
Struttura tipica (fuori da Magento) con composer.json e sorgenti del modulo:
{
"name": "acme/module-demo",
"description": "Modulo demo per Magento 2",
"type": "magento2-module",
"license": "OSL-3.0",
"autoload": {
"files": [
"registration.php"
],
"psr-4": {
"Acme\\Demo\\": ""
}
},
"require": {
"php": ">=8.1"
}
}
Una volta pubblicato su un repository (privato o Packagist) e richiesto nel progetto Magento, verrà installato in vendor,
abilitabile come qualsiasi modulo.
13) Comandi utili durante lo sviluppo
php bin/magento cache:flushper svuotare cache durante iterazioni rapide.php bin/magento setup:di:compilein modalità production o per intercettare errori di DI.php bin/magento setup:static-content:deployper asset statici (utile in production).php bin/magento dev:urn-catalog:generateper autocompletamento degli XSD in IDE.php bin/magento module:disableemodule:enableper isolare conflitti.
14) Checklist finale
- Il modulo è registrato (
registration.php) e dichiarato (module.xml). - Cache e upgrade eseguiti correttamente.
- Rotte e controller rispettano area frontend/adminhtml.
- Template con escaping, logica spostata in Block/Service dove opportuno.
- Schema DB dichiarativo + patch per dati e migrazioni.
- Admin con ACL e menu corretti.
- Eventuali plugin/observer sono mirati e documentati.
- Composer package pronto se serve distribuire.
Con questi elementi puoi costruire moduli robusti e manutenibili, estendendo Magento in modo compatibile con upgrade futuri. Da qui, i passi successivi tipici sono: UI Components per griglie e form admin, GraphQL, queue (RabbitMQ) per processi asincroni, e suite di test automatici (unit/integration) per evitare regressioni.