New Laravel module worked locally but "Class not found" in production: the composer merge plugin
A new module built with nwidart/laravel-modules worked on every laptop, then production threw Class not found for its service provider right after deploy. The module's composer.json was never merged into the root autoloader in the image build, and an authoritative classmap froze the result.
We added a new Reporting module to our Laravel application, which uses nwidart/laravel-modules. Tests
passed in CI, the feature worked on every developer laptop and in the review environment. The production
deploy failed the health check immediately, and the log showed:
production.ERROR: Class "Modules\Reporting\Providers\ReportingServiceProvider" not found
{"exception":"[object] (Error(code: 0): Class \"Modules\\Reporting\\Providers\\ReportingServiceProvider\"
not found at /var/www/vendor/laravel/framework/src/Illuminate/Foundation/ProviderRepository.php:206)
The module's service provider class could not be autoloaded. Other modules, created a year earlier, worked.
How modules are autoloaded
Since version 11 of nwidart/laravel-modules, each module has its own composer.json with a PSR-4 autoload
section, and the root project uses the wikimedia/composer-merge-plugin to merge those into the root
autoloader:
// Modules/Reporting/composer.json
{
"name": "acme/reporting",
"autoload": {
"psr-4": {
"Modules\\Reporting\\": "app/",
"Modules\\Reporting\\Database\\Factories\\": "database/factories/",
"Modules\\Reporting\\Database\\Seeders\\": "database/seeders/"
}
}
}
// root composer.json (excerpt)
"extra": {
"merge-plugin": {
"include": ["Modules/*/composer.json"]
}
}
Comparing environments
On a laptop:
grep -c "Reporting" vendor/composer/autoload_psr4.php
# 3
In the production image:
docker run --rm registry.example.com/billing-app:2025.10.31 \
grep -c "Reporting" vendor/composer/autoload_psr4.php
# 0
The production autoloader had no entry for the new module. The older modules were there.
The Dockerfile
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --optimize-autoloader --classmap-authoritative
COPY . .
RUN composer dump-autoload --no-dev --optimize --classmap-authoritative
Two things were wrong. First, composer install ran with only the root composer.json and lock file
present, so the merge plugin found no module files to include. The second dump-autoload, after copying the
code, should have fixed that, but the merge plugin is a Composer plugin, and the project's allow-plugins
setting did not list it. Developers had allowed it once in their global Composer configuration on their
laptops, so local installs ran it; the CI image had no such global setting, and Composer skipped the plugin
in non-interactive mode with only a warning.
Second, --classmap-authoritative tells the autoloader to trust the classmap only and never look on disk.
Without the PSR-4 mapping, the module's classes were not in the classmap, and the autoloader did not try
anything else.
Why did older modules work? Their namespaces had been added to the root composer.json by hand two years
ago, before the team adopted the merge plugin. Nobody had removed those entries.
What we ruled out
- Case-sensitive file paths. A classic cause of "works on macOS, fails on Linux"; the paths matched.
- OPcache with stale files. The container was new; there was no previous cache.
- The module not being enabled.
modules_statuses.jsonlisted it as enabled.
Root cause
The production build never merged the new module's autoload configuration: the merge plugin was not allowed
to run in the image build, and the first install happened before module files existed. The optimized,
authoritative classmap then guaranteed that the missing classes could not be found at runtime. Older modules
worked only because of leftover manual entries in the root composer.json.
Composer plugins and allow-plugins
Since Composer 2.2, plugins must be explicitly allowed in config.allow-plugins. In non-interactive runs, a
plugin that is not allowed is skipped, with a warning that is easy to miss in build logs:
wikimedia/composer-merge-plugin contains a Composer plugin which is blocked by your allow-plugins config.
classmap-authoritative
| Option | Behaviour |
|---|---|
| Default PSR-4 | Look up namespace prefix, find file on disk |
--optimize |
Generate a classmap, fall back to PSR-4 on miss |
--classmap-authoritative |
Classmap only; a miss means "class does not exist" |
Authoritative classmaps are fast and safe only if the classmap is complete.
Who is affected
Laravel projects using nwidart/laravel-modules 11 or later (which relies on the merge plugin), any project
using the merge plugin for local packages, and Docker builds that install dependencies before copying the
application code to benefit from layer caching.
Environment
| Component | Version / value |
|---|---|
| Laravel | 12 |
| nwidart/laravel-modules | 12.x |
| Merge plugin | wikimedia/composer-merge-plugin 2.x |
| Composer | 2.8 in the composer:2 image |
| Build | Multi-stage Docker build, --classmap-authoritative |
Allow the plugin
"config": {
"allow-plugins": {
"wikimedia/composer-merge-plugin": true
}
}
Copy module manifests before installing
For layer caching to work and the merge plugin to see every module, copy the module composer.json files
along with the root files:
Docker's COPY cannot preserve directory structure when copying a glob such as Modules/*/composer.json,
so we use a small build stage that collects only the manifests with their paths:
FROM alpine:3.20 AS manifests
WORKDIR /src
COPY . .
RUN find Modules -name composer.json -maxdepth 2 | tar -cf /manifests.tar -T -
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
COPY --from=manifests /manifests.tar /tmp/
RUN tar -xf /tmp/manifests.tar && composer install --no-dev --no-scripts --prefer-dist
COPY . .
RUN composer dump-autoload --no-dev --optimize --classmap-authoritative --strict-psr
Now the install layer is cached until a manifest changes, and the merge plugin sees all modules.
--strict-psr makes Composer fail if files do not match PSR-4 expectations, which catches misnamed classes.
Remove the leftover manual entries
The root composer.json no longer lists module namespaces; the merge plugin is the single source. That way
a misconfigured build fails for every module at once, loudly, instead of failing only for new ones.
Fail the build on missing classes
A build step boots the application and lists providers, which fails immediately if any class is missing:
RUN php artisan package:discover --ansi && php artisan about --only=environment
Alternatives and trade-offs
- Add each module's namespace to the root
composer.jsoninstead of using the merge plugin. Simpler and explicit, but every new module needs a root change, which is what the plugin avoids. - Drop
--classmap-authoritativeand use--optimizeonly. A missing classmap entry then falls back to PSR-4, which would have worked here, but hides incomplete builds.
How to test the fix
docker build -t app:test .
docker run --rm app:test php -r 'require "vendor/autoload.php"; var_dump(class_exists("Modules\\Reporting\\Providers\\ReportingServiceProvider"));'
# bool(true)
Confirming in production
The rebuilt image started, the Reporting module's routes responded, and autoload_psr4.php in the image
contained entries for every module. The next new module, a month later, deployed without any change to
root configuration.
Regression guard
- CI builds the production image on every pull request and runs the
class_existscheck for each module's service provider, generated frommodules_statuses.json. - Build logs are scanned for "blocked by your allow-plugins config"; the build fails if it appears.
Security implications
allow-plugins exists because Composer plugins execute code during install. Allowing a plugin should be a
deliberate decision for a trusted package, which is why it is listed explicitly rather than allowing all
plugins. Production images are built without dev dependencies and without running scripts from untrusted
packages.
Prevention checklist
- Allow the composer merge plugin explicitly if your modules depend on it.
- Make module manifests available before
composer installin Docker builds. - Keep one source of truth for module autoloading.
- Use
--classmap-authoritativeonly with a build check that every expected class exists. - Build and smoke-test the production image in CI.
References
- nwidart/laravel-modules, Installation and autoloading: https://laravelmodules.com/docs/12/getting-started/installation-and-setup
- wikimedia/composer-merge-plugin: https://github.com/wikimedia/composer-merge-plugin
- Composer, allow-plugins: https://getcomposer.org/doc/06-config.md#allow-plugins
- Composer, Autoloader optimization: https://getcomposer.org/doc/articles/autoloader-optimization.md
Add a label
Embed this post
<iframe src="https://www.hibug.me/laravel-module-class-not-found-composer-merge" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have been writing PHP since before Composer existed and Laravel since its early versions. Today I build modular Laravel applications for payment and subscription businesses and maintain a few open-source packages in my spare time. I write about the practical side of that work: structuring large Laravel codebases, publishing and versioning packages, two-factor authentication that actually works for every user, and what really happens during an on-call night on a payments platform. Each post comes from something I shipped or fixed, with code you can adapt.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.