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.
- Pick a vendor + module name and create the directory 01
- Write registration.php at the module root 02
- Write etc/module.xml, declare name + version 03
- Add behaviour, config XML, PHP classes, layout, templates 04
- Enable the module and run setup:upgrade 05
Most common mistake Editing files under vendor/ directly
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.
-
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. Createapp/code/Vendor/Module/. Do not useMagentoor 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 thatregistration.phphands toComponentRegistrar, and every path inside the module starts from it. -
02
Write
registration.phpat the module rootOne file, one function call. The body is literally
ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Vendor_Module', __DIR__);wrapped in<?phpwith theuse Magento\Framework\Component\ComponentRegistrar;import, no PHPnamespacedeclaration, 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 theVendor_Modulestring is the canonical key used everywhere else (di.xmlmodule="..."attributes, layout XML handles,app/etc/config.phpentries). -
03
Write
etc/module.xml, declare name + versionThe root element is
<config>with the Magento module XSD, and inside it one<module name="Vendor_Module"/>element. The oldsetup_versionattribute 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. -
04
Add behaviour, config XML, PHP classes, layout, templates
Now the actual work goes in. Config XML lives in
etc/:di.xmlfor preferences and plugins,events.xmlfor observers,adminhtml/system.xmlfor admin config fields,frontend/routes.xmlandadminhtml/routes.xmlfor controllers,webapi.xmlfor REST,schema.graphqlsfor GraphQL,crontab.xmlfor scheduled jobs. PHP classes go intoBlock/,Model/,Controller/(admin ones underController/Adminhtml/),Plugin/andObserver/. Patches go intoSetup/Patch/Data/andSetup/Patch/Schema/. Storefront files go intoview/frontend/layout/,view/frontend/templates/andview/frontend/web/. PSR-4 mapsVendor\Module\Block\Footoapp/code/Vendor/Module/Block/Foo.phpfor you. -
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_Moduleadds the module to themodulesarray inapp/etc/config.phpwith value1. Thenbin/magento setup:upgradeapplies your schema and data patches. In production mode I follow that withsetup:di:compileandsetup:static-content:deploy. Runningsetup:upgradeagain is safe: applied patches are recorded in thepatch_listtable and do not run a second time unless you delete their row.
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 getetc/,Controller/,Setup/Patch/andview/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.jsonfrom the first commit. Settype: magento2-module, add theautoload.psr-4mapping andregistration.phpunderautoload.files, requiremagento/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, orafterbehaviour, declared indi.xml. Plugins should always live in their own module, even tiny ones, because the<type name="Magento\Catalog\Model\Product">declaration indi.xmlapplies globally and you want thePlugin/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. ImplementDataPatchInterface, code the change inapply(), and Magento records the patch class name in thepatch_listtable 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.
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/directlyThe single most common, and most damaging, mistake. Edits to
vendor/magento/module-customer/Model/Customer.phpget wiped by the nextcomposer install, never reach the git history, can’t be code-reviewed, and silently disappear when a teammate runscomposer update. The Magento extension model exists precisely so you never have to touch vendor code: override classes viadi.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 avendor/file in an IDE to make a change, stop and create a module instead. -
Forgetting
<sequence>when patches depend on core modulesYour data patch reads from
customer_entity, or your schema patch adds a foreign key pointing atcatalog_product_entity. Without a<sequence><module name="Magento_Customer"/></sequence>in youretc/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:upgradefails withcolumn not foundon 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_Magentowith a top-level classVendor\Magento\Customer\Plugininvites 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 matchesMagento,Adobe, or another vendor’s namespace. PHPStan / Psalm catch most of these at static-analysis time if you have them in CI.
Magento module structure, frequently asked questions
-
app/code vs vendor/, where should my module live?
Useapp/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. Usevendor/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 byComponentRegistrar, 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 acomposer.jsonreference and ships the source from a separate repo. On this site, Panth_* extensions live invendor/mage2kishan/(reusable) and project-specific work lives inapp/code/Panth/. -
Do I need
composer.jsonfor anapp/code/module?Technically no, a module inapp/code/only needsregistration.phpandetc/module.xmlto function, and the PSR-4 autoload is inferred from the directory layout. Magento’s own modules invendor/magento/all shipcomposer.jsonfiles, but that’s because they are Composer packages. For a strictly local module, you can skip it. Practically, ship acomposer.jsonanyway: 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 runbin/magento module:enable Vendor_Module? Until that command runs, Magento knows nothing about the module, checkapp/etc/config.php’smodulesarray for theVendor_Module => 1entry. Second: did you runbin/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 runbin/magento setup:di:compileandbin/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 atvar/log/exception.logandvar/log/system.logfor 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 yourcomposer.jsondeclaresrequire: magento/module-customer, Composer enforces the dependency at install time.<sequence>inmodule.xmlenforces it atsetup:upgradetime. Both are useful, both are different. -
Can I share
Block/andModel/classes between modules?Technically yes, PSR-4 happily resolvesVendor\ModuleA\Block\Foofrom module A anduseit 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 ausestatement. 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 asrequireon the shared one incomposer.json, and never reach across sibling modules directly. -
How does Magento autoload my class?
Two-step. First, on every bootstrap Magento runs everyregistration.phpin theapp/code/andvendor/trees, each call toComponentRegistrar::register()tells Magento “moduleVendor_Modulelives at__DIR__”. Second, when PHP encounters a class reference likenew \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\Foobecomesapp/code/Vendor/Module/Block/Foo.php.Magento\Framework\Code\Generatorhandles a few synthetic classes (Interceptors, Proxies, Factories) on top, generated intogenerated/code/, but it uses the same PSR-4 resolution as the rest.
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.