Chat on WhatsApp
Magento glossary

What is a Magento module structure ?

A Magento 2 module is a self-contained directory under app/code/Vendor/Module/ (or vendor/vendor/module/ if installed via Composer) that bundles config XML, PHP classes, layout, templates, and patches into a single namespaced package. The minimum two files are registration.php (calls ComponentRegistrar::register) and etc/module.xml (declares name + setup version). Every Magento extension — core, marketplace, or bespoke — follows this structure.

How it works

Five steps from empty directory to loaded module

A Magento module is a contract: tell the framework where the code lives, declare the module name, drop in behaviour, and run setup:upgrade. Here is the wiring, end to end.

  1. 01

    Pick a vendor + module name and create the directory

    Naming convention is Vendor_Module — CamelCase, no spaces, no hyphens. The vendor segment is your namespace prefix (your company name, your handle, anything you own) and the module segment describes what the module does. Create app/code/Vendor/Module/. do not use Magento, Adobe, or any Panth_* prefix already in this repo — those namespaces are reserved and the PSR-4 autoloader will collide. The directory you create becomes the root that registration.php hands to ComponentRegistrar, and every relative path inside the module is resolved from there.

  2. 02

    Write registration.php at the module root

    One file, one function call. The body is literally ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Vendor_Module', __DIR__); wrapped in <?php with the use Magento\Framework\Component\ComponentRegistrar; import — no PHP namespace declaration, no class. This file is what Magento scans on every bootstrap to discover modules: __DIR__ tells the registrar where the module lives on disk, and the Vendor_Module string is the canonical key used everywhere else (di.xml module="..." attributes, layout XML handles, app/etc/config.php entries).

  3. 03

    Write etc/module.xml — declare name + version

    Root element is <config> with the Magento module XSD. Inside, one <module name="Vendor_Module" setup_version="0.0.1"/> element (the setup_version is legacy — Magento 2.3+ uses data/schema patches, but the attribute is still required). Optionally add <sequence><module name="Magento_Customer"/></sequence> listing modules whose data + schema patches must run before yours. Sequence is what guarantees that when your patch tries to read a Magento_Customer column, that column already exists. Without it, patch order is alphabetical — usually wrong.

  4. 04

    Add behaviour — config XML, PHP classes, layout, templates

    Now you fill in the module. Drop config XML into etc/di.xml for DI preferences and plugins, events.xml for observers, system.xml for admin config fields, frontend/routes.xml + adminhtml/routes.xml for controllers, webapi.xml for REST/GraphQL, crontab.xml for scheduled jobs. PHP classes go into Block/, Model/, Controller/Frontend/ or Controller/Adminhtml/, Helper/, Plugin/, Observer/. Patches go into Setup/Patch/Data/ and Setup/Patch/Schema/. Frontend assets go into view/frontend/layout/, view/frontend/templates/, view/frontend/web/css/. PSR-4 maps Vendor\Module\Block\Foo to app/code/Vendor/Module/Block/Foo.php automatically.

  5. 05

    Enable the module and run setup:upgrade

    Dropping files into app/code/Vendor/Module/ is not enough — Magento needs to be told to load it. Run bin/magento module:enable Vendor_Module, which writes an entry to app/etc/config.php’s modules array (value 1). Then bin/magento setup:upgrade runs your data + schema patches and registers schema versions. In production mode, follow with bin/magento setup:di:compile and bin/magento setup:static-content:deploy. Re-running setup:upgrade is idempotent — patches are tracked in the patch_list table and never re-execute unless you manually DELETE the row.

When to use

Four scenarios where building a module is the right call

Not every customization needs a module — a theme override or a single CMS block edit might be enough. These four scenarios are where the module overhead pays back immediately.

  • Site-specific customization bigger than a theme override

    A view/frontend/layout/ override in your theme handles simple block-move and template-swap work. When the customization grows past that — you need a new controller, a new admin config field under Stores → Configuration, a new EAV attribute on the customer entity, a custom REST endpoint — you have crossed the threshold where a module is the right tool. Modules give you all of etc/, Controller/, Setup/Patch/, and view/adminhtml/ in one package, scoped to a single namespace and reversible by disabling the module. Themes can’t do any of that.

  • Reusable feature you plan to ship as a Composer extension

    If the customization could be useful to more than one store — a payment integration, a shipping carrier, an ERP connector, a new admin grid — build it as a module from day one. Add composer.json at the module root with type: magento2-module, declare your autoload.psr-4 mapping, list magento/framework as a dependency, and you can ship the same module to Magento Marketplace, to Packagist as an open-source package, or to a private Composer repo for client work. do not retrofit this later — converting an app/code/ module to a Composer module after the fact means rewriting paths everywhere.

  • Plugin (interceptor) around an existing Magento class

    Magento’s plugin system lets you intercept any public method on any class with before, around, or after behaviour, declared in di.xml. Plugins should always live in their own module — even tiny ones — because the <type name="Magento\Catalog\Model\Product"> declaration in di.xml applies globally and you want the Plugin/ directory next to it to be self-contained. Putting plugins inside a kitchen-sink “Customization” module bloats the module and makes upgrades brittle. One module per concern is the rule.

  • Data patch that seeds rows without touching core code

    You need to seed a CMS page, add a system config value, populate a custom table, or migrate data from a legacy column — and you want it to run once per environment, tracked, idempotent. That is what Setup/Patch/Data/ is for. Implement DataPatchInterface, code the change in apply(), and Magento records the patch class name in the patch_list table after first execution. The patch never touches vendor code, never modifies Magento core, and travels through staging and production cleanly. The module that holds the patch can be otherwise empty — that’s a valid pattern.

Common mistakes

Three module-structure mistakes that cause real outages

Every junior-Magento-engineer audit I run surfaces at least one of these three. Catch them before they hit production.

  • Editing files under vendor/ directly

    The single most common — and most damaging — mistake. Edits to vendor/magento/module-customer/Model/Customer.php get wiped by the next composer install, never reach the git history, can’t be code-reviewed, and silently disappear when a teammate runs composer update. The Magento extension model exists precisely so you never have to touch vendor code: override classes via di.xml <preference for="..." type="..."/>, intercept methods via plugins in your own module, listen for events via observers, override layout via theme XML. If you find yourself opening a vendor/ file in an IDE to make a change, stop and create a module instead.

  • Forgetting <sequence> when patches depend on core modules

    Your data patch reads from customer_entity, or your schema patch adds a foreign key pointing at catalog_product_entity. Without a <sequence><module name="Magento_Customer"/></sequence> in your etc/module.xml, Magento has no guarantee that Magento_Customer’s patches run before yours — module load order is alphabetical-ish but not reliable. Symptom: setup:upgrade fails with column not found on first install but succeeds on the second run, or fails on a clean staging env but works locally. Add the sequence and the bug disappears.

  • Module name shadowing a core namespace

    Naming your module Vendor_Magento with a top-level class Vendor\Magento\Customer\Plugin invites autoloader collisions — the PSR-4 resolver matches the shortest namespace prefix it can find, and ambiguity here causes intermittent “class not found” failures that only appear in production after compile. The safer rule: vendor segment is unique to your org (Acme, Webaccess, Panth), module segment is descriptive (CustomerExport, StripePlugin), and no segment ever matches Magento, Adobe, or another vendor’s namespace. PHPStan / Psalm catch most of these at static-analysis time if you have them in CI.

FAQ

Magento module structure — frequently asked questions

  • app/code vs vendor/ — where should my module live?
    Use app/code/Vendor/Module/ for site-specific customisations that will never be reused on another project — the file you only ever need on this one store. Use vendor/vendor/module/ (via Composer) for reusable modules: anything you might ship to a second project, sell on Marketplace, or install across multiple environments with version pinning. The technical behaviour is identical — both directories are scanned by ComponentRegistrar, both are PSR-4 autoloaded, both can hold the same files. The difference is workflow: app/code/ commits the source into your project repo; vendor/ commits a composer.json reference and ships the source from a separate repo. On this site, Panth_* extensions live in vendor/mage2kishan/ (reusable) and project-specific work lives in app/code/Panth/.
  • Do I need composer.json for an app/code/ module?
    Technically no — a module in app/code/ only needs registration.php and etc/module.xml to function, and the PSR-4 autoload is inferred from the directory layout. Magento’s own modules in vendor/magento/ all ship composer.json files, but that’s because they are Composer packages. For a strictly local module, you can skip it. Practically, ship a composer.json anyway: it documents the module name, version, license, and dependencies on other modules (require: magento/module-customer: ^104.0), and it’s the trivial first step if you ever decide to extract the module into Composer later. Five minutes of work now saves a refactor in 18 months.
  • I dropped the files in — why isn’t my module showing up?
    Three checks, in order. First: did you run bin/magento module:enable Vendor_Module? Until that command runs, Magento knows nothing about the module — check app/etc/config.php’s modules array for the Vendor_Module => 1 entry. Second: did you run bin/magento setup:upgrade? Until that command runs, your data + schema patches haven’t executed and any DB-backed features are missing. Third: in production mode, did you run bin/magento setup:di:compile and bin/magento cache:clean? Production caches generated DI classes — new classes need a recompile. If all three are done and the module still doesn’t load, look at var/log/exception.log and var/log/system.log for XML parse errors in your config files.
  • <sequence> vs <depends> — what’s the difference?
    <sequence> is about setup-time ordering: it guarantees that the listed module’s data + schema patches run before this module’s patches. Use it whenever your module’s patches read from or write to another module’s tables. Runtime dependencies — needing a class from another module at request time — are handled through normal constructor injection. Magento 2 doesn’t have a runtime <depends> tag; the old M1 concept was replaced by DI. If your composer.json declares require: magento/module-customer, Composer enforces the dependency at install time. <sequence> in module.xml enforces it at setup:upgrade time. Both are useful, both are different.
  • Can I share Block/ and Model/ classes between modules?
    Technically yes — PSR-4 happily resolves Vendor\ModuleA\Block\Foo from module A and use it in module B as long as both are loaded. The mechanism that makes this work is exactly the same as Composer dependencies between any two PHP packages. Practically, this is discouraged for site-specific code because it creates hidden coupling: module B silently breaks when module A is disabled, and the dependency isn’t declared anywhere except inside a use statement. If you genuinely need to share code, the right pattern is to extract the shared classes into a third “library” module, declare both module A and B as require on the shared one in composer.json, and never reach across sibling modules directly.
  • How does Magento autoload my class?
    Two-step. First, on every bootstrap Magento runs every registration.php in the app/code/ and vendor/ trees — each call to ComponentRegistrar::register() tells Magento “module Vendor_Module lives at __DIR__”. Second, when PHP encounters a class reference like new \Vendor\Module\Block\Foo(), the registered Composer autoloader looks up the module root that matches the namespace prefix, then resolves the rest of the namespace as a path relative to that root — Vendor\Module\Block\Foo becomes app/code/Vendor/Module/Block/Foo.php. Magento\Framework\Code\Generator handles a few synthetic classes (Interceptors, Proxies, Factories) on top, generated into generated/code/, but it uses the same PSR-4 resolution as the rest.
Module review

Need a Magento module built or audited?

Send the requirement or a link to the existing module — I will review the structure, the di.xml, the patches, and the test coverage, then reply with a written plan, fixed-price quote, and earliest start date. 24-business-hour turnaround.