Skip to content
5.0 Upwork, 100+ reviews Adobe Certified since 2021 New: become your own Magento developer Learn more →
Magento glossary

What is a Magento module structure?

Short answer A Magento 2 module is one folder under app/code/Vendor/Module/ (or vendor/vendor/module/ when Composer installs it) that holds its own config XML, PHP classes, layout, templates and patches under one namespace. Two files make it a module: registration.php, which calls ComponentRegistrar::register, and etc/module.xml, which declares the name. Core modules, Marketplace extensions and the custom modules I write for stores all follow the same layout, so once you know it you can open any of them and find your way around.

How it works
  1. Pick a vendor + module name and create the directory 01
  2. Write registration.php at the module root 02
  3. Write etc/module.xml, declare name + version 03
  4. Add behaviour, config XML, PHP classes, layout, templates 04
  5. Enable the module and run setup:upgrade 05
Each step is explained below

Most common mistake Editing files under vendor/ directly

How it works

Five steps from empty directory to loaded module

Building a module comes down to five steps: tell Magento where the code lives, declare the name, add the behaviour, then enable it and run setup:upgrade. This is the order I do it in.

  1. 01

    Pick a vendor + module name and create the directory

    The name is Vendor_Module, CamelCase, no spaces or hyphens. The vendor part is a namespace you own (your company or your handle) and the module part says what it does. Create app/code/Vendor/Module/. Do not use Magento or another vendor’s name as your vendor part, or your classes will clash with theirs in the PSR-4 autoloader. This folder is the root that registration.php hands to ComponentRegistrar, and every path inside the module starts from it.

  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

    The root element is <config> with the Magento module XSD, and inside it one <module name="Vendor_Module"/> element. The old setup_version attribute is optional since 2.3 if you use data and schema patches, which is what I do on every new module. Add <sequence><module name="Magento_Customer"/></sequence> when your module depends on another one loading first. That way, when your patch reads a Magento_Customer table, the table is already there.

  4. 04

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

    Now the actual work goes in. Config XML lives in etc/: di.xml for preferences and plugins, events.xml for observers, adminhtml/system.xml for admin config fields, frontend/routes.xml and adminhtml/routes.xml for controllers, webapi.xml for REST, schema.graphqls for GraphQL, crontab.xml for scheduled jobs. PHP classes go into Block/, Model/, Controller/ (admin ones under Controller/Adminhtml/), Plugin/ and Observer/. Patches go into Setup/Patch/Data/ and Setup/Patch/Schema/. Storefront files go into view/frontend/layout/, view/frontend/templates/ and view/frontend/web/. PSR-4 maps Vendor\Module\Block\Foo to app/code/Vendor/Module/Block/Foo.php for you.

  5. 05

    Enable the module and run setup:upgrade

    Files in app/code/Vendor/Module/ do nothing until Magento is told to load them. bin/magento module:enable Vendor_Module adds the module to the modules array in app/etc/config.php with value 1. Then bin/magento setup:upgrade applies your schema and data patches. In production mode I follow that with setup:di:compile and setup:static-content:deploy. Running setup:upgrade again is safe: applied patches are recorded in the patch_list table and do not run a second time unless you delete their row.

When to use

Four scenarios where building a module is the right call

Not every change needs a module. A theme override or a CMS block edit is often enough. These are the four cases where I go straight to a module.

  • Site-specific customization bigger than a theme override

    A layout override in your theme is fine for moving a block or swapping a template. Once you need a new controller, an admin field under Stores > Configuration, a new customer attribute or a custom REST endpoint, a theme cannot do it and you need a module. You get etc/, Controller/, Setup/Patch/ and view/adminhtml/ in one package, and you can switch the whole change off by disabling the module.

  • Reusable feature you plan to ship as a Composer extension

    If the code could help more than one store (a payment integration, a shipping carrier, an ERP connector, an admin grid), I build it as a module with its own composer.json from the first commit. Set type: magento2-module, add the autoload.psr-4 mapping and registration.php under autoload.files, require magento/framework, and the same code can go to the Marketplace, to Packagist or to a private Composer repo. Adding this later is possible, but it is more work than doing it up front.

  • 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.

Kishan Savaliya Online 10 AM to 7 PM IST Chat on WhatsApp