Laminas ConfigAggregator Merge Order Is Your Configuration API
ConfigAggregator merges module.config.php files with autoload overrides in a deterministic order. Learn why later providers win, numeric keys get replaced, and how to keep environment tweaks out of modules.
27 Jul 2025, 17:41 UTC

Adding a second module to a Laminas MVC application and watching a route or service silently disappear is a common configuration surprise. The useful takeaway is to treat ConfigAggregator merge order as part of your public API: later providers win, deep merges happen for associative arrays, and numeric keys are replaced, not appended.
The merge you actually get
ConfigAggregator is the component that collects configuration from many providers and produces one merged array. In a typical Laminas MVC skeleton it loads module.config.php files from each enabled module, then files from config/autoload/*.global.php and config/autoload/*.local.php.
Merge semantics are deterministic. Providers are processed in the order they are registered, and later values override earlier ones. For associative arrays the merge is recursive, so nested keys like laminas-router or laminas-servicemanager are combined. For scalar values the later provider silently overwrites the earlier one with no warning.
Numeric keys are a known edge case. If two providers define a numerically indexed list under the same key, the later list replaces the earlier list rather than concatenating. This is the behavior that most often causes routes or view helpers to vanish when a new module is added.
Module.config.php as an encapsulation boundary
Each module can declare its own routes, controllers, services and view helpers in module.config.php. The ServiceManager then consumes the merged config to wire factories, invokables, abstract factories and aliases declaratively.
A minimal module config looks like this:
// module/AcmeBlog/config/module.config.php
return [
'router' => [
'routes' => [
'blog' => [
'type' => Laminas\Router\Http\Literal::class,
'options' => [
'route' => '/blog',
'defaults' => [
'controller' => AcmeBlog\Controller\IndexController::class,
'action' => 'index',
],
],
],
],
],
'controllers' => [
'factories' => [
AcmeBlog\Controller\IndexController::class => AcmeBlog\Factory\IndexControllerFactory::class,
],
],
'service_manager' => [
'factories' => [
AcmeBlog\Service\PostService::class => AcmeBlog\Factory\PostServiceFactory::class,
],
],
];
The module does not need to know about the application bootstrap. The application only needs to enable the module, and ConfigAggregator merges the module configuration into the global config.
Environment overrides without touching modules
Base defaults live in modules and in config/autoload/*.global.php. Per-environment tweaks, secrets and feature flags are conventionally placed in config/autoload/local.php, which is gitignored.
Example override:
// config/autoload/local.php
return [
'AcmeBlog' => [
'db' => [
'dsn' => 'mysql:host=dev-db;dbname=blog',
],
],
'service_manager' => [
'factories' => [
AcmeBlog\Service\PostService::class => AcmeBlog\Factory\DevPostServiceFactory::class,
],
],
];
Because local.php is loaded after module configs, its values override the module defaults. This keeps environment-specific changes out of version control and out of module code.
To verify precedence, you can expose the merged config for inspection in a development route that reads the config from the container. Run the check from the web root with read access to config files. The route should be disabled in production because it leaks configuration.
Where the merge bites you
Deep merging helps composition but hides scalar conflicts. If two modules define the same scalar key, the later provider wins silently. There is no built-in conflict report.
Numeric key replacement is the other frequent pitfall. Two modules both defining 'view_helpers' => ['invokables' => [...]] with numeric keys will result in only the later module's helpers being present unless you use associative keys.
Relying on config for complex runtime logic also makes unit testing harder. Factories that read many nested config keys can hide side effects and make it difficult to reason about defaults.
Practical checks:
- List enabled modules and the order providers are registered in config/application.config.php.
- Review merged config for keys you expect from multiple modules, especially routes and view_helpers.
- Avoid numeric indexes for lists that should accumulate. Use named keys where possible.
Actionable closing
Document the provider order for your application and treat it as a contract. Keep module.config.php focused on defaults that are safe to override. Put environment-specific values in config/autoload/local.php and never modify module code for per-environment tweaks. When adding a new module, diff the merged config before and after to confirm routes, services and aliases are present as intended.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.