Registrars are a useful part of CodeIgniter’s module system. They allow a module or package to contribute values to an application’s configuration without editing files inside app/Config.
This works well for simple properties, but it becomes harder when several modules need to update a nested array or an ordered list. Should the new value replace the old one? Should both arrays be combined? If they are lists, where should the new items be placed?
Until now, Registrars had only one built-in answer: a shallow merge.
CodeIgniter 4.8 introduces Merge directives, which let a Registrar state its intention explicitly while keeping the existing behavior fully backward compatible.
Try Merge Directives Today with 4.8-dev
Merge directives are planned for CodeIgniter 4.8. If you want to test them before the release, follow the official Next Minor Version instructions for the App Starter.
For a new project:
composer create-project codeigniter4/appstarter ci-registrar-demo
cd ci-registrar-demo
php builds next
composer update
For an existing App Starter project, you usually only need:
php builds next
composer update
A Quick Registrar Refresher
Imagine a module that wants to add a template to Config\Pager. It can provide an implicit Registrar:
<?php
namespace Acme\Blog\Config;
class Registrar
{
public static function Pager(): array
{
return [
'templates' => [
'blog' => 'Acme\Blog\Views\Pager',
],
];
}
}
When CodeIgniter creates the Pager configuration object, it discovers this Registrar and merges the returned values into the matching properties.
Explicit Registrars work too. A configuration class can list Registrar classes in its $registrars property. Merge directives behave the same way with both kinds.
Where the Shallow Merge Becomes a Problem
Consider this configuration:
class Example extends BaseConfig
{
public array $options = [
'cache' => [
'handler' => 'file',
'backup' => 'dummy',
],
'debug' => false,
];
}
A module wants to change only the cache handler:
public static function Example(): array
{
return [
'options' => [
'cache' => [
'handler' => 'redis',
],
],
];
}
Registrars use array_merge() at the property level. That means the merge is shallow: the module’s cache array replaces the existing cache array.
The result is:
[
'cache' => [
'handler' => 'redis',
],
'debug' => false,
]
The backup setting has disappeared. This is not necessarily wrong. Shallow replacement has always been the Registrar contract, and existing packages may rely on it. Changing every Registrar to use a deep merge would be a backward compatibility break.
Why Not Use array_merge_recursive()?
At first, replacing array_merge() with array_merge_recursive() sounds like an easy solution. Unfortunately, PHP’s recursive merge does not replace conflicting scalar values. It collects them into an array:
$current = ['handler' => 'file'];
$module = ['handler' => 'redis'];
array_merge_recursive($current, $module);
// ['handler' => ['file', 'redis']]
That is not a valid cache handler. Lists can also gain unwanted duplicates, and an empty array cannot clearly mean “remove the current values”.
There is no single merge algorithm that is correct for every configuration shape. A nested map, an ordered filter list, and a scalar driver name need different behavior.
The Registrar has the most context, so CodeIgniter 4.8 lets it choose.
Deep Merge with Merge::byKey()
To update the cache handler while preserving its sibling settings, wrap the property value in Merge::byKey():
<?php
namespace Acme\Blog\Config;
use CodeIgniter\Config\Merge;
class Registrar
{
public static function Example(): array
{
return [
'options' => Merge::byKey([
'cache' => [
'handler' => 'redis',
],
]),
];
}
}
The result is now:
[
'cache' => [
'handler' => 'redis',
'backup' => 'dummy',
],
'debug' => false,
]
The rule is straightforward:
- string keys are merged recursively
- integer keys are appended
- scalar values are replaced
The name byKey() is intentional. Its behavior is different from array_merge_recursive(), especially when scalar values collide.
Replace a Value Explicitly
Sometimes replacement is exactly what you want. Merge::replace() discards the current value and uses the new one as-is:
public static function Cache(): array
{
return [
'handler' => Merge::replace('redis'),
];
}
It accepts any value, including strings, booleans, null, and arrays.
replace() is especially important inside byKey(). Plain arrays there continue to merge by key, so an empty array does not clear an existing nested array:
return [
'globals' => Merge::byKey([
'after' => [], // keeps the existing items
]),
];
To reset the list, make that intention explicit:
return [
'globals' => Merge::byKey([
'after' => Merge::replace([]),
]),
];
You can think of byKey() as “continue merging” and replace() as “stop merging here”.
Add Items to Configuration Lists
Configuration often contains lists rather than associative maps. Filters are a good example because their order can affect request handling.
Suppose the application starts with:
'before' => ['csrf', 'invalidchars'],
'after' => ['toolbar'],
A blog module can add its filters without rebuilding either list:
<?php
namespace Acme\Blog\Config;
use CodeIgniter\Config\Merge;
class Registrar
{
public static function Filters(): array
{
return [
'globals' => Merge::byKey([
'before' => Merge::after('csrf', ['blogAuth']),
'after' => Merge::prepend(['blogMetrics']),
]),
];
}
}
The result is:
'before' => ['csrf', 'blogAuth', 'invalidchars'],
'after' => ['blogMetrics', 'toolbar'],
Four directives are available for lists:
Merge::append([...])adds missing items to the endMerge::prepend([...])adds missing items to the frontMerge::before($anchor, [...])places items before an existing itemMerge::after($anchor, [...])places items after an existing item
The list directives do not introduce duplicate values. append() and prepend() leave an item where it already exists, while before() and after() can move an existing item next to the anchor.
If the anchor is missing, before() behaves like prepend() and after() behaves like append().
Combining Directives
The useful part is not any single directive, but the ability to combine them at the point where each decision belongs:
public static function Filters(): array
{
return [
'globals' => Merge::byKey([
'before' => Merge::append(['blogFilter']),
'after' => Merge::replace([]),
]),
];
}
Here:
byKey()preserves other keys insideglobalsappend()addsblogFilterto the existingbeforelistreplace([])deliberately clears theafterlist
That is much clearer than trying to infer all three intentions from ordinary arrays.
How It Works Internally
The implementation is deliberately small. A Merge object is a value object containing a strategy, a value, and, when needed, an anchor.
During Registrar processing, BaseConfig checks each returned property value:
if ($value instanceof Merge) {
$this->{$property} = $this->applyMerge(
$this->{$property} ?? null,
$value,
);
continue;
}
If the value is not a Merge directive, CodeIgniter follows the existing Registrar path unchanged.
This gives the feature an explicit boundary. Directives are recognized as property values and recursively inside Merge::byKey(). Values passed to replace() or the list directives are treated literally.
There is no global switch and no change to the meaning of an ordinary Registrar array.
Backward Compatibility Was the Main Constraint
The important design decision is that this feature is optional:
return [
'options' => [
// Existing shallow behavior
],
];
Existing Registrars continue to work exactly as before. A package opts into richer behavior only where it returns a Merge directive:
return [
'options' => Merge::byKey([
// Explicit deep merge
]),
];
This gives package authors more control without silently changing applications that already depend on shallow merging.
Merge directives also do not create a new priority system. If several Registrars modify the same property, they are still applied in discovery order. The directives explain how each Registrar should modify the value when its turn arrives.
As before, values from .env take priority over Registrar values.
When to Use Each Directive
For most cases, the choice can be reduced to a few questions:
- Keep the current Registrar behavior? Return a normal value or array.
- Update selected keys in a nested array? Use
Merge::byKey(). - Replace a value or clear a nested array? Use
Merge::replace(). - Add items to the start or end of a list? Use
prepend()orappend(). - Place list items relative to something already there? Use
before()orafter().
Applications with simple configuration do not need to use this API at all. It is mainly useful for reusable modules and packages that must cooperate with configuration owned by the application or other modules.
Final Thoughts
Config merging looks simple until different kinds of data need different rules. Making all Registrars recursive would solve some cases while breaking others, and PHP’s array_merge_recursive() has semantics that are rarely suitable for configuration overrides.
Merge directives in CodeIgniter 4.8 avoid guessing. They preserve the original shallow behavior and let a Registrar describe the exact operation only when it needs more control.
That makes modular configuration safer, but it also makes the code easier to read: the merge policy is visible exactly where the value is registered.