Come sviluppare un modulo Magento

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::get nel 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:flush per svuotare cache durante iterazioni rapide.
  • php bin/magento setup:di:compile in modalità production o per intercettare errori di DI.
  • php bin/magento setup:static-content:deploy per asset statici (utile in production).
  • php bin/magento dev:urn-catalog:generate per autocompletamento degli XSD in IDE.
  • php bin/magento module:disable e module:enable per isolare conflitti.

14) Checklist finale

  1. Il modulo è registrato (registration.php) e dichiarato (module.xml).
  2. Cache e upgrade eseguiti correttamente.
  3. Rotte e controller rispettano area frontend/adminhtml.
  4. Template con escaping, logica spostata in Block/Service dove opportuno.
  5. Schema DB dichiarativo + patch per dati e migrazioni.
  6. Admin con ACL e menu corretti.
  7. Eventuali plugin/observer sono mirati e documentati.
  8. 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.

Torna su