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.
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.
-
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. Createapp/code/Vendor/Module/.do notuseMagento,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 thatregistration.phphands toComponentRegistrar, and every relative path inside the module is resolved from there. -
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 + versionRoot 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. -
04
Add behaviour — config XML, PHP classes, layout, templates
Now you fill in the module. Drop config XML into
etc/—di.xmlfor DI preferences and plugins,events.xmlfor observers,system.xmlfor admin config fields,frontend/routes.xml+adminhtml/routes.xmlfor controllers,webapi.xmlfor REST/GraphQL,crontab.xmlfor scheduled jobs. PHP classes go intoBlock/,Model/,Controller/Frontend/orController/Adminhtml/,Helper/,Plugin/,Observer/. Patches go intoSetup/Patch/Data/andSetup/Patch/Schema/. Frontend assets go intoview/frontend/layout/,view/frontend/templates/,view/frontend/web/css/. PSR-4 mapsVendor\Module\Block\Footoapp/code/Vendor/Module/Block/Foo.phpautomatically. -
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. Runbin/magento module:enable Vendor_Module, which writes an entry toapp/etc/config.php’smodulesarray (value1). Thenbin/magento setup:upgraderuns your data + schema patches and registers schema versions. In production mode, follow withbin/magento setup:di:compileandbin/magento setup:static-content:deploy. Re-runningsetup:upgradeis idempotent — patches are tracked in thepatch_listtable and never re-execute unless you manuallyDELETEthe row.
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 underStores → 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 ofetc/,Controller/,Setup/Patch/, andview/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.jsonat the module root withtype: magento2-module, declare yourautoload.psr-4mapping, listmagento/frameworkas 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 notretrofit this later — converting anapp/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, 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.