skip to content

In a Laravel package, why call mergeConfigFrom() in register(), and what goes wrong when an app's published config overrides a nested array?

level: seniorimportance: should knowfreq 38%

answer

  1. package defaults under the app's copy
  2. array_merge: app keys win
  3. first level only
  4. nested arrays replaced whole
  5. skipped when config is cached

basics

~20 s

mergeConfigFrom() loads a package's default config under a key and lets the app's published copy override it. It merges only the first level, so a nested array in the app's copy replaces the package's whole.

solid answer

~40 s

`$this->mergeConfigFrom(__DIR__.'/../config/audit-trail.php', 'audit-trail')` sets `config('audit-trail')` to `array_merge(packageDefaults, appConfig)`: the app's values win, and keys the app never published fall back to the package. Calling it in `register()` makes the values available before any provider boots and reads them. The catch is that `array_merge` is **shallow**. If the package default has `'redact' => ['password', 'token', 'ssn']` and the app's copy sets `'redact' => ['iban']`, the result is `['iban']`, not all four; and if the app's copy omits a nested key inside `'store' => [...]`, that key is simply missing. Laravel 13's base provider also has `replaceConfigRecursivelyFrom()`, which uses `array_replace_recursive` instead. Both methods do nothing when the configuration is cached, because the cached file already contains the merged result.

code

php · 21 lines
php
<?php

// Package default: config/audit-trail.php
return [
    'enabled' => true,
    'store' => [
        'driver' => 'database',
        'table' => 'audit_logs',
    ],
];

// App's published copy: config/audit-trail.php
return [
    'store' => [
        'table' => 'audits',
    ],
];

// After mergeConfigFrom():
// config('audit-trail.enabled')      => true
// config('audit-trail.store.driver') => null  (nested array replaced)

go deeper

for a junior

Know that mergeConfigFrom lets a package ship default config so apps only publish and change what they need.

for a middle

Explain array_merge order (app wins), first-level-only merging, and why the call sits in register().

for a senior

Design package config to survive shallow merges: flat keys, defaults on nested reads, or replaceConfigRecursivelyFrom with care for list arrays.

for a principal

Set config conventions for in-house packages so three or thirty apps can upgrade them without re-publishing or silent missing keys.

## What `mergeConfigFrom()` does A package ships a default config file, for example `config/audit-trail.php`. Apps may publish a copy to change some options. `mergeConfigFrom()` combines the two so the app only has to write the options it changes: ```php public function register(): void { $this->mergeConfigFrom(__DIR__.'/../config/audit-trail.php', 'audit-trail'); } ``` In source, the method: 1. Returns immediately if the application's configuration is cached. 2. Otherwise reads the current `config('audit-trail')` (the app's published copy, or an empty array if there is none). 3. Sets `config('audit-trail')` to `array_merge(require $packageFile, $appConfig)`. Because the app's array is the **second** argument, its keys override the package's. Keys the app never mentions keep the package default, including options added in later package versions. That is why a published config file does not need re-publishing after every update. ## Why `register()` The documentation places the call in `register()`. Registration happens for every provider before any provider boots, so by the time boot code (the package's own, or another package's) reads `config('audit-trail.enabled')`, the merged values are in place. ## The shallow-merge trap `array_merge` works on the **first level only**. Nested arrays are not merged; the app's version replaces the package's entirely: | Package default | App's published copy | `config(...)` result | |---|---|---| | `'enabled' => true`, `'queue' => 'default'` | `'queue' => 'audit'` | `enabled` true, `queue` audit | | `'redact' => ['password', 'token', 'ssn']` | `'redact' => ['iban']` | `['iban']` only | | `'store' => ['driver' => 'database', 'table' => 'audit_logs']` | `'store' => ['table' => 'audits']` | `['table' => 'audits']`, no `driver` | The last row is the classic bug: the app meant to rename the table, and the package now fails because `audit-trail.store.driver` is `null`. The documentation warns that if users partially define a multi-dimensional array, the missing options will not be merged. ## Ways around it - **Keep config flat** where possible: `'store_driver'` and `'store_table'` as top-level keys merge correctly. - **Read nested keys with a default** in the package: `config('audit-trail.store.driver', 'database')`. - **Use `replaceConfigRecursivelyFrom()`**, which the base provider in Laravel 13 also offers. It merges with `array_replace_recursive`, so nested keys the app omits keep the package default. Note that list-style arrays are replaced **by index**: `['iban']` over `['password', 'token', 'ssn']` gives `['iban', 'token', 'ssn']`, which is rarely what a redaction list wants. - **Document** in the published file which arrays are replaced whole. ## Why not just tell apps to publish? Without a merge, the package would read only the app's published file, and every app would have to publish and maintain a complete copy. Each package release that added an option would then require every app to update its copy, or the new option would be `null`. With `mergeConfigFrom()`, the three apps that use the audit-trail package can skip publishing entirely and still get working defaults, and apps that do publish can delete every line they do not change. A short published file is also easier to review: it lists exactly what an app does differently. ## Interaction with configuration caching Both merge methods return early when the configuration is cached. That is safe: the command that builds the cache boots the application, runs every provider's `register()`, and writes the merged result into the cache file. What does **not** work is changing the package's default file on a server with a cached config and expecting the new default to appear; the cache must be rebuilt. ## Reading config inside the package Package code should read its options through `config()` with dot notation, and pass a sensible default for any value that might be missing after a partial override: - `config('audit-trail.enabled', true)` - `config('audit-trail.store.table', 'audit_logs')` Reading the whole array once and indexing into it, as in `config('audit-trail')['store']['driver']`, turns a missing nested key into a PHP warning instead of a default. Typed getters such as `Config::string('audit-trail.queue')` fail loudly when an app sets the wrong type, which is useful for options that must be strings or integers. ## The shared audit-trail package For the three internal apps the team keeps top-level options flat (`enabled`, `queue`, `retention_days`) and uses one nested array, `redact`, documented as "replaces the default list; include every field you need". Apps that do not publish the file get the package defaults automatically.

  • Does mergeConfigFrom() break when the app runs with cached configuration?
    No. It skips work when config is cached, but the cache was built by booting the app, which ran every provider's `register()` and stored the merged values. It only becomes a problem if the package's default file changes on a server whose cache was not rebuilt.
  • Why must the package's config file avoid closures?
    Configuration values end up in the cached config file, which is written as a PHP array. The documentation warns that closures in config files cannot be serialized correctly when the cache is built, so packages should store class names or scalar values instead.

saying these in an interview costs you the question

  • mergeConfigFrom deep-merges nested arrays from the package
  • The package's defaults override the app's published values
  • Apps must re-publish the config after every package update to get new keys
  • mergeConfigFrom throws when configuration is cached
  • mergeConfigFrom belongs in boot() so other providers are ready