Skip to content

Bug: BaseConfig issue on registrar #9231

Closed

Description

@ChibuezeAgwuDennis

PHP Version

8.2

CodeIgniter4 Version

4.5.5

CodeIgniter4 Installation Method

Manual (zip or tar.gz)

Which operating systems have you tested for this bug?

Windows, Linux

Which server did you use?

apache

Database

MySQL

What happened?

New registrar with the same method with other registrars replaces their contents on array_merge which works on only first level array. I hope array_merge_recursive will be better for modules

Steps to Reproduce

\Codeigniter\Config\BaseConfig

    /**
    * Provides external libraries a simple way to register one or more
    * options into a config file.
    *
    * @return void
    *
    * @throws ReflectionException
    */
    protected function registerProperties()
    {
        if (! static::$moduleConfig->shouldDiscover('registrars')) {
            return;
        }

        if (! static::$didDiscovery) {
            $locator        = service('locator');
            $registrarsFiles = $locator->search('Config/Registrar.php');

            foreach ($registrarsFiles as $file) {
                $className = $locator->findQualifiedNameFromPath($file);

                if ($className === false) {
                    continue;
                }

                static::$registrars[] = new $className();
            }

            static::$didDiscovery = true;
        }

        $shortName = (new ReflectionClass($this))->getShortName();

        // Check the registrar class for a method named after this class' shortName
        foreach (static::$registrars as $callable) {
            // ignore non-applicable registrars
            if (! method_exists($callable, $shortName)) {
                continue; // @codeCoverageIgnore
            }

            $properties = $callable::$shortName();

            if (! is_array($properties)) {
                throw new RuntimeException('Registrars must return an array of properties and their values.');
            }

            foreach ($properties as $property => $value) {
                if (isset($this->{$property}) && is_array($this->{$property}) && is_array($value)) {
                    $this->{$property} = array_merge($this->{$property}, $value);
                } else {
                    $this->{$property} = $value;
                }
            }
        }
    }

Expected Output

Expected merge the old contents array with the new one

Anything else?

Yes i used array_merge_recursive. I discovered the issue to so i extend the base config to child class today fix this but i think the usage of array_merge_recursive is a better option help on this to update the core code

\Codeigniter\Config\BaseConfig

    /**
    * Provides external libraries a simple way to register one or more
    * options into a config file.
    *
    * @return void
    *
    * @throws ReflectionException
    */
    protected function registerProperties()
    {
        if (! static::$moduleConfig->shouldDiscover('registrars')) {
            return;
        }

        if (! static::$didDiscovery) {
            $locator        = service('locator');
            $registrarsFiles = $locator->search('Config/Registrar.php');

            foreach ($registrarsFiles as $file) {
                $className = $locator->findQualifiedNameFromPath($file);

                if ($className === false) {
                    continue;
                }

                static::$registrars[] = new $className();
            }

            static::$didDiscovery = true;
        }

        $shortName = (new ReflectionClass($this))->getShortName();

        // Check the registrar class for a method named after this class' shortName
        foreach (static::$registrars as $callable) {
            // ignore non-applicable registrars
            if (! method_exists($callable, $shortName)) {
                continue; // @codeCoverageIgnore
            }

            $properties = $callable::$shortName();

            if (! is_array($properties)) {
                throw new RuntimeException('Registrars must return an array of properties and their values.');
            }

            foreach ($properties as $property => $value) {
                if (isset($this->{$property}) && is_array($this->{$property}) && is_array($value)) {
                    $this->{$property} = array_merge_recursive($this->{$property}, $value);
                } else {
                    $this->{$property} = $value;
                }
            }
        }
    }

Activity

  1. added
    bugVerified issues on the current code behavior or pull requests that will fix them
    on Oct 20, 2024
  2. michalsn commented on Oct 21, 2024

    @michalsn
    Member

    Please give us an example of the problem: a config file and a registrar.

    Are you defining the same registrar in many modules?

  3. added
    waiting for infoIssues or pull requests that need further clarification from the author
    on Oct 21, 2024
  4. PANKAJNAGAR433 commented on Oct 21, 2024

    @PANKAJNAGAR433
    class MyModuleRegistrar {
        public static function MyModule() {
            return [
                'database' => 'mysql',
                'cache' => ['enabled' => true, 'driver' => 'file'],
            ];
        }
    }
  5. neznaika0 commented on Oct 21, 2024

    @neznaika0
    Contributor

    The problem is visible on the Filters. Try disabling only the Toolbar or adding filters to the previous config. They will be overwritten from the module

  6. michalsn commented on Oct 21, 2024

    @michalsn
    Member

    I will assume this is the proper example:

    <?php
    
    namespace Config;
    
    use CodeIgniter\Config\BaseConfig;
    
    class Example extends BaseConfig
    {
        public array $arrayNested = [
            'key1' => 'val1',
            'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3'],
        ];
    }
    <?php
    
    namespace Modules\MyModule\Config;
    
    class Registrar
    {
        public static function Example(): array
        {
            return [
                'arrayNested' => [
                    'key2' => ['val4' => 'subVal4']
                ],
            ];
        }
    }

    Now, you will get this:

    public 'arrayNested' -> array (2) [
            'key1' => string (4) "val1"
            'key2' => array (1) [
                'val4' => string (7) "subVal4"
            ]
        ]

    But you expect this:

    public 'arrayNested' -> array (2) [
            'key1' => string (4) "val1"
            'key2' => array (3) [
                'val2' => string (7) "subVal2"
                'val3' => string (7) "subVal3"
                'val4' => string (7) "subVal4"
            ]
        ]

    This is not a bug, but a feature request. Personally, I'm not a fan of this change.

    You can avoid this problem very simply - by defining the structure of the config file differently.

  7. removed
    bugVerified issues on the current code behavior or pull requests that will fix them
    on Oct 21, 2024
  8. neznaika0 commented on Oct 21, 2024

    @neznaika0
    Contributor

    how? let's say I initially want to disable the toolbar filter in app/Registrar.php, and add middlewares in other modules modules/auth, modules/job?
    Some add data, others replace it.
    the .env file will not work. It is not rational to create another config class - then why use a standard interceptor?

  9. michalsn commented on Oct 21, 2024

    @michalsn
    Member

    If we start using array_merge_recursive, we will introduce a BC break.

    If modules add new options, they can use the Publisher class to add them to the main config.

    I could be wrong, but I think the intention was to prevent everything from being easily overwritten from within modules, as there should be a "single source of truth" for some options.

  10. neznaika0 commented on Oct 22, 2024

    @neznaika0
    Contributor

    Yes, this is BC. Therefore, another solution is needed. How about passing the current configuration properties to the Config?

    // in registerProperties()
    static::$registrars[] = new $className();
    //...
    // in foreach()
    $callable->__parentProps = get_class_vars($this);
    
    
    // in Registrars, delete some keys
    $parentProps = $this->__parentProps;
    unset($parentProps['key1']);
    
    return [
                'arrayNested' => [
                    'key2' => array_merge_recursive($parentProps, ['val4' => 'subVal4'])
                ],
            ];

    This is about the order of work, code is inaccurate

  11. ChibuezeAgwuDennis commented on Oct 22, 2024

    @ChibuezeAgwuDennis
    Author

    Certainly! Let's explore this issue in detail, starting with examples of the problem.

    Example of the Problem: Config File and Registrar

    In a typical modular CodeIgniter 4 application, you might have several independent modules, each with its own Registrar.php file. These registrars are meant to register specific configuration settings, such as filters, routes, or other properties, into a central configuration system.

    Example Configuration Files

    1. Blog Module Registrar:

      // Modules/Blog/Config/Registrar.php
      namespace Modules\Blog\Config;
      
      class Registrar
      {
          public function filters()
          {
              return [
                  'aliases' => [
                      'blogFilter' => \Modules\Blog\Filters\BlogFilter::class,
                  ],
                  'globals' => [
                      'before' => ['blogFilter'],
                  ],
              ];
          }
      }
    2. Shop Module Registrar:

      // Modules/Shop/Config/Registrar.php
      namespace Modules\Shop\Config;
      
      class Registrar
      {
          public function filters()
          {
              return [
                  'aliases' => [
                      'shopFilter' => \Modules\Shop\Filters\ShopFilter::class,
                  ],
                  'globals' => [
                      'before' => ['shopFilter'],
                  ],
              ];
          }
      }
    3. Paypal Module Registrar:

      // Modules/Paypal/Config/Registrar.php
      namespace Modules\Paypal\Config;
      
      class Registrar
      {
          public function filters()
          {
              return [
                  'aliases' => [
                      'paypalFilter' => \Modules\Paypal\Filters\PaypalFilter::class,
                  ],
                  'globals' => [
                      'before' => ['paypalFilter'],
                  ],
              ];
          }
      }

    These files define filters that are supposed to be registered into the main configuration. The expectation is that all these configurations should be combined, so the application uses filters from all modules.

    The Problem: Overwriting Instead of Merging

    The issue arises due to the way the registerProperties method handles the merging of configuration arrays. The current implementation uses array_merge, which works fine for single-dimensional arrays but doesn't perform a deep merge. This means that when you have nested arrays, the nested arrays from one module can overwrite those of another module instead of merging them.

    For example, if the filters method is defined similarly in multiple modules, the last one processed will overwrite the previous ones rather than integrating all of them.

    Are You Defining the Same Registrar in Many Modules?

    Yes, in this context, the same registrar method (filters) is defined in multiple modules. Each module has its own implementation of the filters method within its Registrar.php file. The intention is for each module to contribute its filters to the central configuration.

    Solution: Deep Merging

    To solve this issue, a deep merge function is needed. This function should recursively merge arrays to ensure that all configurations are retained and integrated properly. Here's a simple example of how I implement a deep merge function in PHP:

    function deep_merge(array &$array1, array &$array2)
    {
        $merged = $array1;
    
        foreach ($array2 as $key => &$value) {
            if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) {
                $merged[$key] = deep_merge($merged[$key], $value);
            } else {
                $merged[$key] = $value;
            }
        }
    
        return $merged;
    }

    Applying the Solution

    In the registerProperties method, I replace the array_merge operation with this deep_merge function to ensure that all configurations from different modules are properly merged.

    Solution Output:

    After applying the deepMerge function, the combined configuration will look like this:

    // Combined configuration
    $combinedFilters = [
        'aliases' => [
            'blogFilter' => \Modules\Blog\Filters\BlogFilter::class,
            'shopFilter' => \Modules\Shop\Filters\ShopFilter::class,
            'paypalFilter' => \Modules\Paypal\Filters\PaypalFilter::class,
        ],
        'globals' => [
            'before' => ['blogFilter', 'shopFilter', 'paypalFilter'],
        ],
    ];

    Explanation of the Output

    • Aliases: The aliases array now contains all filter aliases from the Blog, Shop, and Paypal modules, ensuring that each module's filter is registered.

    • Globals: The globals array for the before key contains an array with all filter names, ensuring that all specified filters are applied before global processing.

    This method guarantees that all configuration data is retained, allowing each module to seamlessly contribute to the overall application setup. By using a deep merge function, the solution ensures that nested arrays are combined effectively, thereby preserving the unique configurations intended by each module's developer. This approach respects the configurations from all modules and integrates them cohesively, maintaining the integrity of the application's intended settings.

  12. removed
    waiting for infoIssues or pull requests that need further clarification from the author
    on Oct 23, 2024
  13. kenjis commented on Oct 25, 2024

    @kenjis
    Member

    I think filter aliases can be configured in module, but all filters should be configured in app/Config/Filters.php for security.

  14. neznaika0 commented on Oct 25, 2024

    @neznaika0
    Contributor

    What about the appendBeforeProps(), appendAfterProps(), replaceProps() methods Registrar? Should I use the current (old $this) copy of the config inside?

  15. ChibuezeAgwuDennis commented on Oct 26, 2024

    @ChibuezeAgwuDennis
    Author

    I think filter aliases can be configured in module, but all filters should be configured in app/Config/Filters.php for security.

    What of the case the registrar it was stated in the doc that this should be better option

  16. ChibuezeAgwuDennis commented on Oct 26, 2024

    @ChibuezeAgwuDennis
    Author

    What about the appendBeforeProps(), appendAfterProps(), replaceProps() methods BaseConfig? Should I use the current (old $this) copy of the config inside?

    Explain your question better

  17. neznaika0 commented on Oct 27, 2024

    @neznaika0
    Contributor

    This is a solution option. New methods will add or replace Config properties.

    // merge recursive
    // 'aliases' => ['blogFilter' => BlogFilter::class, 'paypalFilter' => PaypalFilter::class]
    'aliases' => $this->appendAfterProps([
        'paypalFilter' => \Modules\Paypal\Filters\PaypalFilter::class,
    ])
    
    // merge recursive
    // 'aliases' => ['paypalFilter' => PaypalFilter::class, 'blogFilter' => BlogFilter::class]
    'aliases' => $this->appendBeforeProps([
        'paypalFilter' => \Modules\Paypal\Filters\PaypalFilter::class,
    ])
    
    // full replace array
    // 'aliases' => ['paypalFilter' => PaypalFilter::class]
    'aliases' => $this->replaceProps([
        'paypalFilter' => \Modules\Paypal\Filters\PaypalFilter::class,
    ])
  18. neznaika0 commented on Feb 7, 2025

    @neznaika0
    Contributor

    Let's get back to the question.

    I think filter aliases can be configured in module, but all filters should be configured in app/Config/Filters.php for security.

    In this case, there is already a security hole - the module can disable all filters.

    // Registrar
        public static function Filters(): array {
            return [
                'required' => [
                    'before' => [],
                    'after' => ['csrf'],
                ],
            ];
        }
    
    // before
        public required -> array (2) [
            'before' => array (2) [
                0 => string (10) "forcehttps"
                1 => string (9) "pagecache"
            ]
            'after' => array (3) [
                0 => string (9) "pagecache"
                1 => string (11) "performance"
                2 => string (7) "toolbar"
            ]
        ]
    
    // after
        public required -> array (2) [
            'before' => array (0) []
            'after' => array (1) [
                0 => string (4) "csrf"
            ]
        ]

    Can we think about how to solve the issue painlessly? Add an option to the Feature class?

  19. sanchawebo commented on Jun 30, 2025

    @sanchawebo
    Contributor

    Any news on this issue? Just ran into it as i was trying to add ci-shield permissions for my module like this:

    public static function AuthGroups(): array
    {
        return [
            'permissions' => [
                'shippinglabel-logos.access' => 'Can access Shipping Label Logos',
                'shippinglabel-logos.create' => 'Can create Shipping Label Logos',
                'shippinglabel-logos.edit'   => 'Can edit Shipping Label Logos',
                'shippinglabel-logos.delete' => 'Can delete Shipping Label Logos',
            ],
            'matrix' => [
                'superadmin' => [
                    'shippinglabel-logos.*',
                ],
            ],
        ];
    }

    And after merging, 'shippinglabel-logos.*' is the only permission left for usergroup 'superadmin' which is very irritating.

  20. michalsn commented on Jun 30, 2025

    @michalsn
    Member

    Any news on this issue?

    There haven’t been any updates so far. Unless more maintainers express agreement on introducing such changes, we won’t be moving forward at this time.

    IMHO, permission definitions should remain centralized in a single main file for security and maintainability reasons.

    However, if you're confident in what you're doing, you can simply use ReflectionClass in your Registrar.php file to merge the base superadmin permission array with additional items.

  21. neznaika0 commented on Apr 8, 2026

    @neznaika0
    Contributor

    The main reason that prevents implementation in the framework is the desire for a single point for changing parameters. If you don't have modules, then it looks strange: edit 20 files, make 100 changes, and check everything again when updating.

    I expressed my opinion in the mind of the package. Try it, @ChibuezeAgwuDennis @warcooft @sanchawebo :

    https://github.com/neznaika0/codeigniter-enhanced-config

  22. ChibuezeAgwuDennis commented on May 11, 2026

    @ChibuezeAgwuDennis
    Author

    @sanchawebo @michalsn @neznaika0 here is another option of how all my modules filters work without registering them in the register.php by extending the discovering of the filter base class

    Here is what we are using and it works perfectly for different modules

    <?php
    
    namespace CMS\Core;
    
    use Config\Modules;
    use Config\Services;
    use CodeIgniter\HTTP\RequestInterface;
    use CodeIgniter\HTTP\ResponseInterface;
    use CodeIgniter\Filters\Filters as CIFilters;
    
    /**
     * Filters
     */
    class Filters extends CIFilters
    {
        protected $module_filters = 'Config/Filters.php';
    
        /**
         * Constructor.
         *
         * @param FiltersConfig $config
         */
        public function __construct($config, RequestInterface $request, ResponseInterface $response, ?Modules $modules = null)
        {
            parent::__construct($config, $request, $response, $modules);
    
            $this->discoverFilters();
        }
    
        /**
         * If discoverFilters is enabled in Config then system will try to
         * auto-discover custom filters files in Namespaces and allow access to
         * the config object via the variable $filters as with the routes file
         *
         * Sample :
         * $filters->aliases['custom-auth'] = \Acme\Blob\Filters\BlobAuth::class;
         */
        private function discoverFilters()
        {
            $locator = Services::locator();
    
            // for access by custom filters
            $filters = $this->config;
    
            $files = $locator->search('Config/Filters.php');
    
            foreach ($files as $file) {
    
                $className = $locator->getClassname($file);
    
                // Don't include our main Filter config again...
                if ($className === 'Config\\Filters') {
    
                    continue;
                }
    
                $modulefilter  = new $className;
    
                $filters->aliases = !isset($modulefilter->aliases) ?: array_merge($filters->aliases, $modulefilter->aliases);
                $filters->filters = !isset($modulefilter->filters) ?: array_merge($filters->filters, $modulefilter->filters);
    
                if (isset($modulefilter->globals)) {
    
                    $filters->globals['before'] = !isset($modulefilter->globals['before']) ?: array_merge($filters->globals['before'], $modulefilter->globals['before']);
                    $filters->globals['after'] = !isset($modulefilter->globals['after']) ?: array_merge($filters->globals['after'], $modulefilter->globals['after']);
                }
    
                $filters->methods = !isset($modulefilter->methods) ?: array_merge($filters->methods, $modulefilter->methods);
    
                $this->config = $filters;
            }
        }
    }
    

    after this we update the Services::filter()

    e.g

    <?php
    
    namespace CMS\Config;
    
    use CMS\Core\Filters;
    use CodeIgniter\Config\BaseService;
    use Config\Services as AppServices;
    use Config\Filters as FiltersConfig;
    
    class Services extends BaseService
    {
        /**
         * Filters allow you to run tasks before and/or after a controller
         * is executed. During before filters, the request can be modified,
         * and actions taken based on the request, while after filters can
         * act on or modify the response itself before it is sent to the client.
         *
         * @return Filters
         */
        public static function filters(?FiltersConfig $config = null, bool $getShared = true)
        {
            if ($getShared) {
                return static::getSharedInstance('filters', $config);
            }
    
            $config ??= config(FiltersConfig::class);
    
            return new Filters($config, AppServices::get('request'), AppServices::get('response'));
        }
    }

    And we just copied the Config/Filter.php in app to our module Config changing it's namespace to our module namespace removing all the app/Config/Filter already set filters from our module modules/Acme/Blob/Config/Filter.php in order not to repeat it
    e.g

    <?php
    
    namespace Modules\Admin\Config;
    
    use CodeIgniter\Config\Filters as BaseFilters;
    use Modules\Admin\Filters\AdminFilter;
    
    class Filters extends BaseFilters
    {
        /**
         * Configures aliases for Filter classes...
         */
        public array $aliases = [
            'admin' => AdminFilter::class,
        ];
    
        /**
         * URI-pattern filters — THIS IS THE GLOBAL ADMIN FILTER
         *
         * Applies AdminFilter::before() to EVERY admin route automatically.
         * Auth routes are respected inside the filter itself.
         */
        public array $filters = [
            'admin' => [
                'before' => ['admin', 'admin/*'],
            ],
        ];
    }

    So try it and give me feedback if it works perfectly. if this is the best solution, someone should update the source code with this

  23. neznaika0 commented on May 11, 2026

    @neznaika0
    Contributor

    It works almost as originally - array_merge() and only one class. I suggested more freedom if you trust the module settings.

  24. Sunchock commented on Jun 18, 2026

    @Sunchock

    Hello everyone,

    We're encountering the exact same problem today in our project, within a modular architecture with multiple configuration files.

    I just stumbled upon this discussion and I don't understand your reluctance to change.

    On the one hand, a simple pull request would add seven characters, which would allow us to find a solution. The security argument is irrelevant, since it's already been demonstrated here that the initial configuration can be overridden at the first level.

    Using a Publisher or the Reflection API simply because you don't want to use "_recursive" seems like a disproportionate and absurd solution.

    The very principle of the Registrar is to be able to save configuration properties on the fly; it should therefore be able to do so for low-level properties in arrays.

    Could we think of others and not block small improvements like this one, which could be useful to many projects?

    We must think about what will help the greatest number of people, not what pleases you. An open-source project should not be a battleground for personal opinions. We must remain objective and improve what can be improved.

  25. michalsn commented on Jun 20, 2026

    @michalsn
    Member

    I want to clarify that this was never about bad will or personal preference. Registrars were originally designed around a shallow merge model. That behavior has existed for a long time, so changing it globally is not just a small internal tweak. It changes the contract that existing applications and packages already rely on.

    The reason we should not simply replace array_merge() with array_merge_recursive() is that it would change registrar behavior globally, and array_merge_recursive() does not express the semantics config files usually need.

    For example, scalar replacement becomes value collection:

    $current  = ['cache' => ['handler' => 'file']];
    $incoming = ['cache' => ['handler' => 'redis']];
    
    array_merge_recursive($current, $incoming);
    
    // ['cache' => ['handler' => ['file', 'redis']]]

    That is probably not what a config merge should do. In many cases, the expected result is simply:

    ['cache' => ['handler' => 'redis']]

    It can also introduce duplicates in list-style config:

    $current  = ['globals' => ['before' => ['csrf']]];
    $incoming = ['globals' => ['before' => ['csrf', 'auth']]];
    
    array_merge_recursive($current, $incoming);
    
    // ['globals' => ['before' => ['csrf', 'csrf', 'auth']]]

    And it does not give a clear way to say "replace this nested array with an empty array":

    $current  = ['globals' => ['after' => ['toolbar']]];
    $incoming = ['globals' => ['after' => []]];
    
    // recursive merge keeps the existing value - it does not reset it

    So the concern is not about blocking recursive merging as a concept. The concern is that changing the default merge algorithm would solve some cases while creating new, subtle behavior changes in others. As maintainers, we have to look at the whole API surface and preserve backward compatibility.

    A solution for this is now proposed in #10329. This keeps the existing registrar behavior unchanged, and adds optional Merge directives for cases that need more control. This lets simple registrars continue working as before, while advanced cases can explicitly choose byKey(), replace(), append(), prepend(), before(), or after() depending on the desired behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions