A Module as a Composer Package
A module becomes a package with type magento2-module and registration.php listed in autoload files.
"type": "magento2-module", "autoload": { "files": ["registration.php"], "psr-4": { "Acme\\Faq\\": "" } }app/code is for a module you are writing in this project. The moment the module has to run in a second project, it becomes a Composer package installed under vendor/, with a version number and a dependency list.
The keys that matter
type must be magento2-module. autoload.psr-4 maps the namespace to the package root, written as an empty string. autoload.files must list registration.php, because that entry is what executes the file. require names the packages the code uses: modules in 2.4.9 allow ~8.3.0||~8.4.0||~8.5.0 for PHP and depend on magento/framework at 103.0.*.
How each location loads
Under vendor/, Composer's generated autoloader includes the files and registers the namespaces. Under app/code, Composer writes neither: the project's root composer.json autoloads app/etc/NonComposerComponentRegistration.php, which globs app/code/*/*/registration.php, and a PSR-0 fallback mapping the empty prefix to app/code/ resolves the classes. A composer.json inside an app/code module is therefore optional, and its autoload section is ignored while the module sits there.
Installing it
Add a repositories entry to the project's root composer.json, run composer require acme/module-faq:^1.0, then bin/magento setup:upgrade and bin/magento cache:flush.
What trips people up
Moving a module to vendor/ without deleting the app/code copy registers the same name twice, which throws a LogicException on every request.
Example
{
"name": "acme/module-faq",
"description": "Customer-facing FAQ pages",
"type": "magento2-module",
"license": "proprietary",
"version": "1.0.0",
"require": {
"php": "~8.3.0||~8.4.0||~8.5.0",
"magento/framework": "103.0.*",
"magento/module-store": "101.1.*"
},
"autoload": {
"files": [
"registration.php"
],
"psr-4": {
"Acme\\Faq\\": ""
}
}
}
When to use it
- Packaging a module you wrote in app/code so a second project can install it.
- Pinning a client's extensions to tagged versions instead of copying directories between servers.
- Diagnosing a vendor module whose classes are not autoloaded after composer require.
- Splitting an optional GraphQL or admin surface into a separate installable package.
More examples
Requiring the package from your own repository
A fragment of the project's root composer.json. A vcs repository lets Composer read the package's own composer.json and its git tags, so ^1.0 resolves to a tag rather than a branch. Reach for this for client-specific modules that should not be published publicly; the module itself needs no change.
{
"repositories": [
{
"type": "composer",
"url": "https://repo.magento.com/"
},
{
"type": "vcs",
"url": "[email protected]:acme/module-faq.git"
}
],
"require": {
"acme/module-faq": "^1.0"
}
}
Installing it into a project
composer require only puts files on disk and regenerates the autoloader; nothing in Magento knows about the module until setup:upgrade records it in app/etc/config.php and applies its schema and data patches. Skipping setup:upgrade is the most common reason a freshly installed extension appears to do nothing.
# in the Magento project root
composer require acme/module-faq:^1.0
bin/magento setup:upgrade
bin/magento cache:flush
# production mode adds
bin/magento setup:di:compile
bin/magento setup:static-content:deploy
One of your modules depending on another
Splitting an optional surface, here a GraphQL layer, into its own package lets a project install the FAQ without it. require guarantees the base package is installed; the sequence entry in etc/module.xml guarantees it loads first. The two statements are separate and you normally want both.
{
"name": "acme/module-faq-graphql",
"type": "magento2-module",
"require": {
"php": "~8.4.0",
"magento/framework": "103.0.*",
"acme/module-faq": "^1.0"
},
"autoload": {
"files": [
"registration.php"
],
"psr-4": {
"Acme\\FaqGraphQl\\": ""
}
}
}
Discussion