Conventions That Keep a Module Readable
Where each kind of file belongs, how directory paths become class names, and one module per concern.
Acme\Faq\Controller\Article\View -> app/code/Acme/Faq/Controller/Article/View.phpMagento's conventions are load-bearing. A file in the wrong directory is not a style problem; it is a file that never runs.
app/code/Acme/Faq/ ├── registration.php ├── composer.json ├── etc/ config read in every area │ ├── module.xml │ ├── frontend/ storefront only │ └── adminhtml/ admin only ├── Controller/ one directory per URL path segment ├── Block/ ViewModel/ classes that feed templates ├── Model/ Model/ResourceModel/ ├── Observer/ Plugin/ ├── Setup/Patch/Data/ data patches ├── view/frontend/ layout/, templates/, web/ ├── i18n/ en_US.csv and friends └── Test/Unit/
Paths are not cosmetic
Classes in app/code are autoloaded through a PSR-0 fallback that maps the empty prefix to app/code/. The namespace is turned straight into a path, so the directory names must match the namespace exactly, capitals included. A folder named controller works on a case-insensitive Mac and fails on the Linux server.
One module, one concern
Name a module after the feature, not the client: Acme_Faq, Acme_GiftMessage. A single Acme_Customisations module ties every unrelated change together, so none of it can be switched off, tested or handed over on its own. Equally, do not split one feature across three modules without a reason; the cost is a dependency graph nobody can read.
Where a dependency belongs
composer.json under require makes the other module installed. <sequence> in module.xml makes it load first. You usually want both.
After moving files
Run bin/magento cache:flush, and bin/magento setup:di:compile if the code is compiled.
Example
<?php
// app/code/Acme/Faq/Controller/Article/View.php
// URL segment "article" is the directory, "view" is the class.
declare(strict_types=1);
namespace Acme\Faq\Controller\Article;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\View\Result\PageFactory;
class View implements HttpGetActionInterface
{
public function __construct(
private readonly PageFactory $pageFactory
) {
}
public function execute(): ResultInterface
{
return $this->pageFactory->create();
}
}
// GET https://example.test/faq/article/view returns 200 and renders the themed
// page. Rename the directory to "articles" and the URL becomes
// /faq/articles/view - the path is the routing table.
When to use it
- Laying out a new module so another developer can find things without asking.
- Deciding whether a change belongs in an existing module or a new one.
- Fixing a class that cannot be found on the server but loads on a developer's Mac.
- Reviewing a module that has grown into a bag of unrelated customisations.
More examples
The same filename in two areas means two different things
This is the admin counterpart of etc/frontend/routes.xml: a different directory, a different router id, and its controllers live in Controller/Adminhtml/. Putting a storefront route in etc/adminhtml/ produces no error and no route. When a configuration change seems ignored, check the area directory before checking the file contents.
<!-- app/code/Acme/Faq/etc/adminhtml/routes.xml -->
<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_faq" frontName="acme_faq">
<module name="Acme_Faq"/>
</route>
</router>
</config>
Package name mirroring the module name
Acme_Faq becomes acme/module-faq: lowercase, hyphenated, with a module- prefix, exactly as the core packages are named. Following it means a reader can go from a module name in an error message to the package in composer.lock without guessing. require is also the honest record of what the module touches, and a long list is a sign the module is doing more than one job.
{
"name": "acme/module-faq",
"type": "magento2-module",
"require": {
"php": "~8.4.0",
"magento/framework": "103.0.*",
"magento/module-store": "101.1.*"
},
"autoload": {
"files": [
"registration.php"
],
"psr-4": {
"Acme\\Faq\\": ""
}
}
}
Declaring the load order to match the dependency
Every entry here should have a matching entry in composer.json require, because sequence only orders what is already installed. Keeping the two lists in step is what lets someone reason about the module from either file. Apply changes with bin/magento setup:upgrade and bin/magento cache:flush.
<!-- app/code/Acme/Faq/etc/module.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Acme_Faq">
<sequence>
<module name="Magento_Store"/>
</sequence>
</module>
</config>
Discussion