modera/module-bundle
Modera Module Bundle provides a modular structure for PHP applications, letting you organize features into self-contained modules with configuration, dependency handling, and lifecycle hooks. It helps keep large codebases maintainable, reusable, and easier to extend.
Frame this correctly before writing any code. modera/module-bundle is not a Laravel package. It is a Symfony 2/3-era bundle whose entire job is to rewrite AppKernel::registerBundles() after composer install / composer update so that newly installed bundles are auto-registered. There is no ServiceProvider, no extra.laravel.providers, and no Laravel-facing API. On a Laravel codebase it has zero production code paths. The only legitimate day-to-day leverage is either (a) direct use in an inherited legacy Symfony app, or (b) reading it as the conceptual ancestor of Laravel's native package discovery.
If you land in the legacy Symfony case:
composer require modera/module-bundle
app/AppKernel.php:
new Modera\ModuleBundle\ModeraModuleBundle(),
composer.json so it runs after dependency changes:
"scripts": {
"post-install-cmd": ["Modera\\ModuleBundle\\Composer\\ScriptHandler::postInstall"],
"post-update-cmd": ["Modera\\ModuleBundle\\Composer\\ScriptHandler::postUpdate"]
}
composer update, then inspect git diff app/AppKernel.php — the diff is the product.Where to look first: the Composer/ namespace. The script handler is the whole bundle: it inspects installed packages for an opt-in extra metadata key and injects the bundle class into AppKernel. Everything else (ModeraModuleBundle itself) is a thin registration shim. Read the handler end-to-end before relying on it — it is a few hundred lines and it mutates your source tree.
First use case: a third-party bundle declares an opt-in extra key in its own composer.json; one composer update later it is live without a hand edit to AppKernel. That is the entire value proposition, and it is the same value proposition Laravel ships natively.
For Laravel: do not port this. The equivalent is extra.laravel.providers in a package's composer.json, consumed by php artisan package:discover and cached in bootstrap/cache/packages.php. Read this repo to understand why that declarative-metadata design beats source mutation, not to adopt it.
Opt-in metadata contract (the reusable idea). The bundle only auto-registers packages that declare intent in their own composer.json — it does not touch every installed package. This is the correct pattern and the one worth copying: implicit registration of everything installed is a boot-ordering and supply-chain hazard. When you build your own module registrar, key it off explicit metadata, never off "package exists in vendor/".
Composer-hook lifecycle (legacy Symfony). The workflow is:
composer require vendor/acme-bundle.post-update-cmd fires ScriptHandler::postUpdate.extra key.AppKernel.php, inserting new Acme\Bundle\AcmeBundle() into registerBundles().The equivalent Laravel workflow needs no hook at all: Composer writes vendor/composer/installed.json, package:discover reads it, and the result is artifact (bootstrap/cache/packages.php), not a rewritten source file. If you are designing a module system, prefer the artifact form — it survives git status, CI, and code review.
Registry-driven registration (the Laravel-native equivalent). When you need internal modularity rather than third-party plugin installation, do this instead of adopting any 2016 bundle:
// config/modules.php
return [
'billing' => App\Modules\Billing\BillingServiceProvider::class,
'reporting' => App\Modules\Reporting\ReportingServiceProvider::class,
];
// app/Providers/ModuleServiceProvider.php
public function register(): void
{
foreach (config('modules', []) as $provider) {
$this->app->register($provider);
}
}
Each module owns its own routes/views/migrations via loadRoutesFrom, loadViewsFrom, loadMigrationsFrom inside its provider — the same separation of concerns the bundle advertises, with none of the source mutation.
Generated manifest at deploy time (the middle ground). If you want discovery without hand-editing config/modules.php, generate it:
// artisan command run in the deploy pipeline, not at request time
$modules = collect(File::directories(app_path('Modules')))
->mapWithKeys(fn ($dir) => [
basename($dir) => "App\\Modules\\".basename($dir)."\\".basename($dir)."ServiceProvider",
])
->filter(fn ($provider) => class_exists($provider))
->all();
File::put(config_path('modules.php'), '<?php return '.var_export($modules, true).';');
This gives you discovery, deterministic ordering (sort the collection), and a reviewable diff — the same constraints the Symfony bundle solved in 2016, satisfied without rewriting kernel files.
Integration tips.
AppKernel.php under normal code review — every composer update produces a reviewable diff. Treat unexpected insertions as a failed install.composer.lock and commit it. The bundle's output depends on the exact resolved package set; floating constraints make AppKernel.php churn non-reproducibly.composer install --no-dev -o in a throwaway container after any dependency change to confirm the handler produces the same kernel in a clean environment.It rewrites your source tree. AppKernel.php is modified by a Composer hook. That breaks immutable containers, CI layer caching, git diff --exit-code checks, and code review on the one file where a bad line takes the whole app down. This is the single biggest reason not to adopt it in 2024-era tooling. Modern equivalents emit a generated artifact and leave source untouched.
Archived and unsupported. Last release 2016-06-13, archived, zero dependents. Expect outright failure on PHP 8 (composer platform constraint refusal, plus likely deprecated reflection, each(), and dynamic-property usage), and no compatibility with Symfony 4+/Flex. There is no branch or tag to pin to that changes this, and issues/PRs on a read-only split repo are not a delivery path.
Register order is implicit and load-bearing. Bundles injected by the handler land wherever the handler places them, which changes service-override precedence. Symptoms are behavioral, not exceptional — "the wrong mailer is used", "the wrong handler wins" — and there is no exception to trace. If you must use it, diff registerBundles() after every composer run and confirm the order is what you expect.
Autoload race conditions. Injection assumes Composer's autoloader already knows the class. --no-dev -o, --classmap-authoritative, and multi-stage Docker builds can all produce a handler run where the class is present but not yet autoloadable, or vice versa. The result is environments that boot differently from the same composer.lock.
Cache invalidation is on you. Symfony's container cache and OPcache can retain a stale bundle list after a deploy that added a bundle. The classic symptom is "works locally, 500 in prod". After any deploy that changes the bundle set: rm -rf var/cache/*, then cache:warmup, then confirm a cold boot.
Boot-time failures are total. A registered bundle whose class is missing is a fatal at AppKernel construction — the whole app is down, no graceful degradation, health checks red. Because the registration is implicit, the error message usually does not name the missing bundle clearly. When debugging a dead boot after a deploy, git log -p app/AppKernel.php is the first command, not the last.
Extension points are minimal by design. There is no config to disable discovery, no event to veto a registration, no way to influence ordering without hand-editing the kernel afterwards. The only "extension point" is the opt-in extra key in the installed package's composer.json — i.e. the control lives with the package author, not with you. That asymmetry is exactly why Laravel's extra.laravel.providers + package:discover design wins: the metadata is declarative, the result is cached, and disabling is a one-line composer.json change.
If you inherit this on a legacy Symfony app, the exit path is: inventory the dynamically injected bundles into an explicit config/bundles.php, adopt symfony/flex (which solves the same problem and is maintained), delete the modera/module-bundle hook from composer.json, then verify with a cold-cache boot and cache:warmup plus the full test suite. Size it as a 2–3 day spike, not a migration.
If you are on Laravel, close the ticket. There is nothing here to integrate — the requirement is already met natively, and adding this dependency would create an unsupported-code audit finding for zero functional gain.
How can I help you explore Laravel packages today?