Web Development Bug Report

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.

Rafael Domínguez Rafael Domínguez @rafaeldominguez · November 01, 2025 · 7 min read · 23 Views
Banner: new Laravel module worked locally but Class not found in production, composer merge plugin and autoload

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.json listed 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.json instead of using the merge plugin. Simpler and explicit, but every new module needs a root change, which is what the plugin avoids.
  • Drop --classmap-authoritative and use --optimize only. 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_exists check for each module's service provider, generated from modules_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 install in Docker builds.
  • Keep one source of truth for module autoloading.
  • Use --classmap-authoritative only with a build check that every expected class exists.
  • Build and smoke-test the production image in CI.

References

Add a label

#laravel #deployment #composer #laravel-modules #autoloading
Written by
Rafael Domínguez @rafaeldominguez

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.